Compare commits

..
50 Commits
Author SHA1 Message Date
jason.woltje c25f69f873 fix(organization): address accounts review and rollback edge cases (#1873) 2026-11-10 14:09:47 -06:00
jason.woltje 675a813940 feat(organization): add locked instance accounts store and CLI (#1873) 2026-11-10 14:09:47 -06:00
jason.woltje a71da41146 fix(webui): U4b re-check fixes, conflict recovery and partial empty state (#1873)
A conflict on a task another writer moved out of the board's server
filter now reads that task from its organization unfiltered, so the
dialog reopens on the current task. An empty result names the
organizations whose tasks were not read instead of saying there are
no tasks.
2026-11-10 20:04:49 +00:00
jason.woltje 4c74e7ea49 fix(webui): U4b review fixes for the task board (#1873)
- Edit and archive require the digest the board read (expect); the route
  refuses a write without it.
- The edit dialog saves against the task it showed, so a poll that reads a
  newer task turns a stale save into task-conflict; the dialog reopens on the
  current task.
- An answer the board cannot read, or one marked sent=unknown, is shown as an
  open outcome, not as a refusal.
- The per-org cap applies after the filters and keeps the most recently
  changed tasks; projects, metrics and the inbox count every task. The board
  sends project, state, assignee and label filters to the server.
- A poll keeps the focus, caret and scroll the person has at the moment it
  answers, not when it started.
- An org whose tasks read fails shows "tasks not read" and is not writable.
- browser.test.mjs pins tasks-board.js in the script order.
2026-11-10 20:04:49 +00:00
jason.woltje ac3a863bec feat(webui): U4b one task board across organizations (#1873)
Replaces the Tasks placeholder with sample 06: every organization's
tasks read through its own bus and tagged with the org, Kanban and List
with the layout remembered per viewer, and org, project, state,
assignee and label filters. A down org keeps its U1 state.

Writes (create, move, edit, archive) go to POST /api/workspace/tasks,
behind the hook fence, owner only and --send only. Each write resolves
only the org it names and calls that org's bus with the person's store
verbs, so the bus's human check, tracker check and digest check all
apply. Refusals reach the person with what was and was not written.

A Vikunja organization's tasks are read through the tasks adapter and
shown read only; the store verbs refuse them with tracker-not-store.
2026-11-10 20:04:49 +00:00
jason.woltje af7d330101 docs: clarify host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje db75994f82 docs: define accounts host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje ac977d833b test(webui): retry the projects grouping click while the nav repaints (#1873)
The workspace nav repaints after async organization and project reads.
Under load (gate at load 14) a repaint landed between the flat-grouping
wait and the org click, and the click found no button. The click now
retries inside the wait until the page shows org grouping.
2026-11-10 13:46:18 -06:00
jason.woltje aa087e7380 fix(webui): U3 review fixes, figures from failed reads say not read (#1873)
A project row's sessions, open tasks and needs-you figures come from
reads beyond the project list; when those failed in the row's
organization the cells now say not read instead of none or 0, and a
note names the organization. Dashboard per-organization figures and
Done this week count only organizations that made the tile's reads, a
dashboard with no answering organization shows no totals, and empty
lists say not read when no organization could make the read. Sessions
now names each session's organization for screen readers. The
org-archive test waits for the setup steps, which render after the
editor title.
2026-11-10 13:35:49 -06:00
jason.woltje 68fd66903b feat(webui): U3 dashboard, projects and inbox across organizations (#1873)
In organization mode #/ is the cross-organization dashboard (sample 02),
#/projects lists every organization's projects with sort, filters and the
shared By org / Flat grouping (sample 04), and #/inbox lists decisions
across organizations with per-organization decide paths and terminal
commands carrying --org and the full id (sample 05). The per-organization
board moves to #/board and the organization's own project list to #/chat,
which keeps the project surface. Reads go through workspace-data.js; a
read an organization could not make says not read, never zero. Polling
repaints in place and keeps focus, caret and scroll.
2026-11-10 13:35:49 -06:00
jason.woltje a7b2199390 test(bus): pin person handlers to their vocabulary (#1873) 2026-11-10 13:32:55 -06:00
jason.woltje 4c495647cd feat(organization): define org rights and role bundles (#1873) 2026-11-10 13:32:55 -06:00
jason.woltje f5cc712a59 feat(bus): export and pin person task verbs (#1873) 2026-11-10 13:32:55 -06:00
jason.woltje 821fbe8e9b test(webui): wait for the bus run button after open (#1873)
open() waits only for the editor title; the setup step enables the bus run
button after its async /setup read. Under load the assert ran first and
failed (A1 final gate, 2 of 4 runs). Wait for the enabled button instead.
2026-11-10 13:30:30 -06:00
jason.woltje d247b4eb61 fix(agents): A1 review fixes for scope, opt-outs and refusal text (#1873)
Two independent reviews (Sol, daybreak-blue) asked for changes.

- An organization's own assistant instance is scoped to that
  organization. Only the root assistant, which no union holds, is
  cross-organization.
- A root opt-out or project exclude whose name a more specific level
  defines is no longer listed as unavailable.
- A malformed root agents.json refuses with "not valid JSON" only; the
  parser's text, which can quote the file's bytes, is dropped.
- A refused root or project var names its key, never its value; an id
  check's quote of a root value is dropped too. A value can be a secret
  typed in the wrong place, and these refusals reach the Console.
- The root assistant's row shows the model the root file sets.

Tests cover each case, with a sentinel value checked absent from the
route body. The design doc records the behavior.
2026-11-10 13:29:11 -06:00
jason.woltje e358886826 feat(agents): A1 agent levels, a read-only union and the Agents grid (#1873)
Phase A slice A1, no authority change. Agents are defined at three levels:
the user's root (<configDir>/agents.json), the organization (roles) and the
project (projects.<p>.agents in the organization file). A project's agents
are their union, the most specific level winning.

- organization: agent-levels.mjs loads and checks the root file (no link,
  owner uid, no group or other write) and project entries (define, narrow
  intersect keys only, exclude), and resolves the union. The organization
  file checks project agents for every reader and leaves them out of the
  loaded business, so buses see what they saw before. Old-path re-export
  in packages/business.
- webui: GET /api/agents/levels, owner only, with no host path, credential
  reference or var other than the model in its body. display.js leaves it
  unscoped. SERVER_PROJECT_KEYS holds agents.
- Agents grid: the level band, a project picker, and per agent its level,
  the file that defines it, memory, status and model, with narrowed keys,
  overrides and what the project does not get.
- Design: docs/plans/2026-11-10_agent-levels-design.md. The roles/ change is
  a proposal outside this branch.
2026-11-10 13:29:11 -06:00
jason.woltje 01b79e1db4 docs(plans): accounts design draft 3, threat row count (#1873) 2026-11-10 13:03:07 -06:00
jason.woltje 0a75cc82f3 docs(plans): accounts design draft 3, luna review fixes (#1873)
Member task writes use the person verbs tasks.* instead of the agent task
path. A member's console cap never carries the org's human, and only the
decide handlers and request reads gain a member branch. VERB_RIGHTS pins
every BOARD_VERBS entry. request.write is reserved, decide excludes
credential mints, and the lead-confirm list becomes decided choices.
2026-11-10 13:02:37 -06:00
jason.woltje c66cd58846 docs(plans): accounts design draft 3, rights-based membership (#1873)
Applies Jason's three rulings of 2026-11-10. Named rights, role bundles
and per-member grants replace the fixed owner and member split. A member
holds org.view, thread.read and board.read by default. decide and
task.write are grantable. The rights table lands first as slice M1.
2026-11-10 12:46:18 -06:00
jason.woltje 69af2fe94b docs(plans): accounts design draft 2, review fix round (#1873)
Applies the luna review (3 changes) and the sol review (10 changes):
per-role transport table with member reads limited to threads, ownsOrg
over every subject of the owning account, memberGrant adapter, verified
snapshot read after the check, authGen session invalidation, one lock
order, random org incarnation, explicit invite callback exception, a
build stamp that refuses the dev owner in stamped copies, and a rollback
that keeps OIDC. Member board reads and task writes move to slice M8
behind an owner ruling.
2026-11-10 12:32:06 -06:00
jason.woltje 4106bf7344 docs(plans): accounts, membership and invites design, Phase M (#1873) 2026-11-10 12:13:32 -06:00
jason.woltje 8630b8dfc0 test(webui): wait for both sidebar lists after the reload (#1873) 2026-11-10 12:01:55 -06:00
jason.woltje 5eb6dda049 fix(webui): U2b review fixes for the workspace dashboard and agents (#1873)
- Agent activity comes from the bus agents read (the state for the run
  holding the role); with no reported state a live agent shows "running",
  never working or idle.
- A session's project and task is the thread its live run claims
  (thread.claims), not the agent's first listed conversation.
- The dashboard reads an organization only after /api/orgs lists it as
  valid; otherwise it shows "Organization unavailable" and reads nothing.
  Agents reads the selected business only for a listed organization.
- A failed conversation read says "Conversation unavailable", not that
  none exists.
- A reported model gets a Change link to Settings, Routing, in the
  dashboard and agents tables.
2026-11-10 11:32:19 -06:00
jason.woltje 8710cc0e1e feat(webui): U2 unified workspace sidebar, org dashboard and agents grid (#1873)
Finishes the U2 WIP (37b0891a3) to samples 01, 03 and 08 of the
2026-11-09 unified workspace set:
- workspace.js: sidebar Organizations and Projects groups (collapsible,
  state remembered), the org dashboard, the Agents levels.
- workspace-data.js: every read these screens make, GET no-store,
  redirect manual; a refused read shows unavailable for that part only.
- shell.css: the html[data-organizations] .ws-* block from the sample.
- bus.js: org-mode sidebar order, pinned Settings, no project tree under
  Projects, focus kept across repaint.
- Honest empty states where the Console has no data (KPIs, metrics, kind).
- Tests: unified-workspace-browser (10), picker/delete/switch browser tests
  follow the sidebar, browser.test script pin.
2026-11-10 11:32:19 -06:00
jason.woltje 364f1ae7a6 fix(tasks): U4a review fixes for the built-in task store (#1873)
Six review findings, one round:
- A selected task-tracker module that fails to load or has no factory refuses boot with tracker-config.
- The expect digest covers the task revision, so archive and unarchive invalidate an old token.
- Position exhaustion and an occupied explicit position refuse task-position-full; no rebalancing yet.
- The store adapter reads roles from the broker's current business document (Broker.taskRoles), so a role added by reload works.
- Only newly requested assignees are checked against live roles; stored ones survive edits, archive and delete.
- Human tasks.create requires a requirement citation (REQ-AREA-N); tasks.edit accepts one.
2026-11-09 19:55:25 -06:00
jason.woltje 9b7bf11afb feat(tasks): U4a, a task store built into each organization (#1873)
PRD 1.20 REQ-TASK-1, REQ-PLUG-1, REQ-WS-8. Each organization keeps its
tasks in its own bus through the new task-tracker plug-in kind. The
default module is the built-in store; Vikunja is the optional module
and a Vikunja organization keeps today's path.

- bus schema 3u to 3v (migration 3u-3v.sql): store_tasks, append-only,
  one row per write, each citing its events row; seven guard triggers.
- broker: the eight role verbs on the store with the slice 1 field
  table checked twice (module, then a before/after field diff); the
  person's tasks.create/edit/archive/unarchive/delete as human.input
  events; the tasks read returns store rows in board order (group,
  position), filtered and paged like tracker rows.
- runtime: each business gets its module at start (organization file
  plugin, else vikunja with a tracker entry, else store); a store
  business with a Vikunja tracker refuses the boot (tracker-config).
- plugins: task-tracker kind with store and vikunja modules.
- cli: the launcher's tracker flag covers store organizations; trail
  prints store writes.
- pins: schema.test counts, the bus migration tests, stack-release #811
  and T46 (store_tasks on the always list; COUNTED unchanged),
  budget-supervisor accepts 3v.

Rebased onto SYS1 by the lead: SYS1 took 3t-3u, so this step is 3u-3v.
2026-11-09 19:25:44 -06:00
jason.woltje feb98d5529 test(cli): compare file bytes in the org tester-init refusal check
Sol review of 221c1e9d3 (P2): the snapshot held only path names, so a
rewrite of .mosaic-org.json before the refusal passed. It now holds names,
types and file bytes; the reviewer's rewrite mutant fails it.
2026-11-09 19:17:28 -06:00
jason.woltje 221c1e9d35 Fix tester project tests for organization refusal 2026-11-09 19:05:09 -06:00
jason.woltje 544918a715 docs: record U1 and SYS1 landings (#1873) 2026-11-09 19:00:25 -06:00
jason.woltje 2c6556ab1e fix(sys1): count project system types in the restore guard and admit the CLI verbs
Sol review sys1-review-r1 (changes 3):
- P1: project_system_types joins stack-release.sh COUNTED and the T45 LISTED pin; the
  post-backup refusal and in-run recount tests now cover it.
- P2: the CLI channel admits project.system-types and project.system-types.set; a new test
  drives both through run() and the real channel module (1/1 red without the fix).
- P2: the #811 migration-reader pin moves to 19 steps ending 3t-3u.
2026-11-09 18:46:36 -06:00
jason.woltje f4bfd3a744 feat(plugins,organization): system types as a plug-in kind (REQ-SYS-1, #1873)
Adds the system-type kind with five modules (coding, creative-writing,
marketing, montage, business-management), an org-level enabled list
validated fail-closed, project selections limited to the org's list,
read verbs, and bus migration 3t-3u. REQ-SYS-2 toolsets are designed and
deferred: they need a role authority contract.
2026-11-09 18:46:35 -06:00
jason.woltje d35447005a Isolate invalid workspace reads and cancel timed-out org work 2026-11-09 18:22:27 -06:00
jason.woltje 2109458b3f feat(webui,cli): U1 cross-organization read model and console verbs (#1873)
GET /api/workspace on the dev-local-owner Console: one snapshot of
projects, sessions, inbox, tasks and per-org metrics across every
organization the owner belongs to. Membership reuses the org list's
admission (organizations.members); each org is read over the same
per-org options the org pages use, with one deadline per org (4000 ms)
and a state (ok, down, starting, timeout, invalid) with a time. Every
row carries its org id; decide commands carry --org. Granted users
and the tester get 403 owner-only. Polling at 10 s, no push.

mosaic console restart and mosaic org reload <id> call the Console's
F3 routes (POST /api/console/restart, POST /api/orgs/<id>/restart/bus)
so gating, proof and trail are the Console's own; both refuse inside
an agent run first.

The cross-org assistant read is designed and stopped: it needs a new
broker read verb or role authority.
2026-11-09 17:54:06 -06:00
jason.woltje 1616d1dd6f docs: BUILD-LOG and session line for PRD 1.20 and defect l (#1873, #1886) 2026-11-09 17:38:26 -06:00
jason.woltje 7a97cfc4f3 fix(bus): make the private store root at start when a reference names the store (#1886)
Adding an agent with a store credential on an instance with no
<configDir>/secrets/agents left the bus unable to start
(credential-file): Credentials refuses a missing store root, and only
issue() made it, which needs a running bus. Start now makes the root
(0700) the same way issue() does, only when some reference has
store: true. Credentials' own refusal (credential-store T1) is unchanged,
and a root with loose modes or a symlink still refuses start.

Tests: two new credential-verbs tests; the creation test fails on
483eccb3d with credential-file. All 171 bus test files run one at a
time: 1713 pass, 0 fail.
2026-11-09 17:10:09 -06:00
jason.woltje 03fe321b12 docs(prd): land version 1.20, the unified workspace (#1873)
Jason approved draft 2 on 2026-11-09 at 22:57Z: "The file looks fine
other than that." The exception is the Montage module name, deferred
since 19:26Z. Amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3
and adds 17 requirements (101 to 118): REQ-WS-1..8, REQ-AGENT-1..3,
REQ-SYS-1..2, REQ-ACCT-1..2, REQ-SETUP-1..2. requirements.json and the
tasks and bus count pins follow.

Tests: 68 pass, 1 fail. The failure is stack-copy.test.mjs (20/1), which
fails the same way on origin/refactor 483eccb3d: npm's pinned install
can't find node-gyp/bin/node-gyp.js in the test Node installation.
2026-11-09 17:04:31 -06:00
jason.woltje 483eccb3dd docs: BUILD-LOG and session line for organizations F3 (#1873) 2026-11-09 16:45:56 -06:00
jason.woltje a8f9f09573 Merge remote-tracking branch 'origin/refactor' into org-f3-merge 2026-11-09 14:07:52 -06:00
jason.woltje 0a73279d62 fix(webui): F3 review round: instances trail line, honest dev-mode README
The instances route now passes the owner trail note, so an organization
instance change appends the actions.jsonl line the README promises (P3).

The README claimed no agent reaches the F3 controls. On a dev local owner
Console that is false: every local request acts as the owner, which the
pinned startup warning already says. The README now states it and names
sign-in as the boundary; a local-agent peer refusal is a follow-up (P1).
2026-11-09 14:07:52 -06:00
jason.woltje 73df1a687f docs: BUILD-LOG for organizations landings #1876-#1881 (#1873) 2026-11-09 14:00:54 -06:00
jason.woltje 999d173fd4 Merge refactor fdd6658a6 into F3 (E2E5 fixes)
app.js busDown conflict: refactor's order (org list read before
bus-status, E2E5 review P2) with F3's Restart the bus attachment on
the Bus unavailable banner.
2026-11-09 13:54:46 -06:00
jason.woltje fdd6658a65 fix(webui): refresh the Settings bus control on every org list read
E2E5 review P2. An open Settings page kept Done after the bus died
because only opening the organization reread bus health. display.js
now announces each fresh list read, org-create.js redraws the setup
steps when the selected org's bus state flips (choices kept, no draft
guard), and app.js reads the list before bus-status so a failed
bus-status read no longer skips it. Regression: healthy, Refresh down
shows Start, Refresh up hides it; red on 8a8edc44b.
2026-11-09 13:53:19 -06:00
jason.woltje e98909d907 Merge refactor 2bed0aebe into F3 (console restart, owner settings writes)
Conflicts kept both sides: rename stage B organization paths and the
relative-launch re-exec with F3 imports, F2 onboarding script and the
north-star fence exemption with F3 console-restart.js and ORG_OWNER_WRITE,
and the /api/config fields from both.
2026-11-09 13:41:20 -06:00
jason.woltje 1b6cac2bdf feat(webui): restart the Console, the org bus and agents from the WebUI (F3)
Self-service operation from the WebUI, for a dev instance first.

- Settings > Console > Restart Console. A foreground `mosaic console
  start` now supervises the webui: the child exits 75 after answering a
  restart and the supervisor relaunches it with the same argv. Org buses
  keep running. /api/config carries a per-process boot id, so the open
  page polls until a new process answers and then reloads. A detached
  Console has no supervisor and answers 409.
- Restart bus on every "Restart needed" receipt and on the bus-down
  banner: POST /api/orgs/<id>/restart/bus restarts a running bus or
  starts a stopped one, then shows a done receipt with the time.
- Settings > Routing: after a save, the page lists how the change
  reaches each agent (next start, session end, thread idle) and offers
  "Restart <agent> now" for a running one. A session is terminated with
  the reason "route change" and launched again; a thread is terminated.
- Organization settings: the signed-in owner's settings, routing,
  agent, plug-in and Model proxy writes now reach the selected org's
  bus instead of answering 403. Org mode exists only under
  --dev-local-owner. Proxy setup stays a terminal step; a dev-only note
  names the two commands.

Every control needs the signed-in owner session, the page's exact
Origin and Sec-Fetch-Site: same-origin. The tester listener, granted
users, API tokens, agents and bus roles are refused before any work.
Each use is written to <dataRoot>/console/actions.jsonl (0600, no
symlinks) with who, when, what and the outcome. The Console and bus
restart trails fail closed. No change to credential handling, the
broker, the bus tenant boundary or v2 schemas.
2026-11-09 13:37:28 -06:00
jason.woltje 8a8edc44b6 Merge refactor 2bed0aebe into E2E5 fixes 2026-11-09 13:30:24 -06:00
jason.woltje d6eff452e3 fix(webui): recover archived organizations and console controls 2026-11-09 13:29:23 -06:00
jason.woltje 2bed0aebeb Merge refactor bad760ce0 into U0 samples 2026-11-09 13:26:39 -06:00
jason.woltje af674e14c1 docs(design): keep the WebUI four-tile logo in the U0 samples; record U0 approval (#1873)
Jason approved the U0 screens on 11-09 with one change: keep the temporary
logo the site already uses. The samples drew the design system's Relay
placeholder; they now draw the WebUI's four-tile mark with the same fills
as processing.css. The direction doc records the approval verbatim, the
deferred Montage name, and the open-question defaults phase U takes.
2026-11-09 13:26:39 -06:00
jason.woltje 1cc09a1cad docs(design): match U0 org dashboard sessions to the main dashboard count (#1873)
The org dashboard listed an idle chief as a session, so Distinctly
Digital showed four sessions there and three on the main dashboard.
The chief has no running session; it stays on the agents screen.
2026-11-09 12:38:03 -06:00
jason.woltje 36c8260d7d docs(design): add U0 unified workspace samples (#1873)
Eight static screens for PRD 1.20 phase U, for Jason's approval
before U1-U5 are built: sidebar with collapsible Organizations and
org marks, cross-org dashboard, org dashboard with docs, KPIs,
metrics, sessions and a settings index, projects across orgs, one
inbox, Kanban and list tasks with tracker badges, side-by-side panes,
and agents at root, org and project level.

Mock data only: three orgs (Home, Distinctly Digital, Forge), ten
projects, no real names or credentials. workspace.css imports
tokens.css and marks each extension EXT 1-10. render.mjs serves the
samples on 127.0.0.1 port 0 and renders 32 PNGs at 1440x900 and
390x844, light and dark, failing on script errors, failed requests,
sideways scroll or unloaded fonts. No WebUI code changes.
2026-11-09 12:36:28 -06:00
157 changed files with 13388 additions and 372 deletions

No files matched your search

+8
View File
@@ -6339,3 +6339,11 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-07 UTC | Organizations S1 — implementation evidence | Organization-root bus and launcher paths, guarded lifecycle and Console launch/stop were implemented locally. Focused Node 26 tests and host regressions passed; the fresh-organization notifier choice, shared-proxy S2 integration, and remaining instance-root consumers are not complete. Exact commands, inventory, failures and boundaries: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. No push or deployment. Gitea issue creation was excluded by the no-network and no-external-communication limits.
2026-11-09 UTC | Unified workspace (#1873) — direction recorded | Before: the 11-07 rulings had one Console switch between organizations from Settings, tasks only in each org queue, and an agent bound to one organization (REQ-USER-1). After: Jason's 11-09 direction and answers recorded in docs/plans/2026-11-09_unified-workspace-owner-direction.md (all orgs visible at once, per-org task stores with a Vikunja option and an aggregated Kanban, root/org/project agents with one cross-org assistant, system type plug-ins, self-service restart and model switch). PRD 1.20 draft 1 written for his exact-text approval; the PRD itself is unchanged. The unpushed 11-07 rulings record (ad8140065) lands with it.
2026-11-09 UTC | Organizations friction round, landings #1876-#1881 (#1873) | Before: refactor 00ef0bd30. A Console launched by a relative path showed Request status unavailable, the overview printed the north star as raw markdown, packages/business and the business command spellings were the only names, Quick Setup ended with no route to a north star, an archived org could not be restored from a fresh page, and a dead bus after setup had no browser control. After, each through one Codex Sol xhigh review round plus a lead or builder fix round: #1876 re-execs a relative Console launch with its absolute script path (build 744753d7e, reproduced 121/0; review APPROVE 0/0/1). #1877 F1 renders the north star as sanitized markdown with a short preview (review APPROVE 0/0/1; live retest 4/0 twice). #1878 rename stage B moves packages/business to packages/organization and adds the org command spellings, keeping the business ones and their messages (builder 6828/6, see report; review CHANGES 0/1/0, fixed in e19db4009). #1879 F2 adds the onboarding interview after Quick Setup and on the project page, with human-only north-star approval (`mosaic project north-star --approve`) and no new agent grant (builder 411/0; review CHANGES 0/1/1: a refused start left its button disabled, fixed in 5937a977f along with the P3 stale approval capability). #1880 lands the U0 unified workspace samples, approved by Jason with the four-tile logo kept; the module names stay undecided at his request. #1881 E2E round 5 fixes restore from a fresh page, show an owner bus Start whenever the org list reads the bus down, say room changes are unavailable on org Consoles and recover the composer (builder 258/0; review CHANGES 0/1/0, 231/0: Settings kept Done after the bus died because only opening the org reread health). Lead fix fdd6658a6: every org list read is announced and the open setup redraws on a flip, and app.js reads the list before bus-status so a failed bus-status read cannot skip it; regression red on 8a8edc44b, 18 touched webui files 199/0 (Node 26.12.1, one file at a time). uiorg redeployed at fdd6658a6, 19:53Z. Open follow-ups: a down bus offers both the F3 banner Restart and the Settings Start, focus can move when setup redraws, and a Console stop also stops the org buses it started. Reports: /mnt/storage/scratch/mosaic-lead/reports/org-{reqget-2,f1,rename-b,f2,u0-design,e2e5}-*.md.
2026-11-09 UTC, organizations F3 (#1873, PR #1884, a8f9f0957). The WebUI restarts the Console (supervised Consoles exit and `mosaic console start` relaunches them), restarts an organization's bus from the bus-down banner with a done receipt, and sends the owner's routing, agent, instance, name, assistant, limits and model proxy writes to the selected organization's bus. Routing apply lists how a change reaches each saved agent and offers Restart NAME now. Console restarts, bus restarts and routing, agent and instance changes append to `<dataRoot>/console/actions.jsonl`. Builder plus two refactor merges: combined list 354/0 on 999d173fd. Sol review CHANGES 1/0/1. P1: on a `--dev-local-owner` Console a local agent can send the right Origin and Fetch Metadata headers and reach these controls. The pinned startup warning and the accepted no-auth dev flags already cover this, but the README claimed no agent reaches them. Lead decision: correct the README and file the cooperative local-agent peer refusal as #1883, without building it in this round. P3: the instances route wrote no trail line. Fixed in 0a73279d6; the regression test fails on 999d173fd and passes on the fix. After the refactor merge, 25 touched or README-reading files 195/0 (Node 26.12.1, one file at a time, env -i). uiorg redeployed at a8f9f0957, 22:45Z; live config offers consoleRestart and org settingsSend. Correction to this session's process: after the test run completed I replied "No response requested" and stopped until Jason asked. Open follow-ups: defect l (a bus restart after adding a credentialed agent can refuse to start), the bus restart refusal code, a stale-lock Recover control, one control for banner Restart and Settings Start, #1883. Report: /mnt/storage/scratch/mosaic-lead/reports/org-f3-review.md.
2026-11-09 UTC, PRD 1.20 and defect l (#1873, #1886; PRs #1887 03fe321b1 and #1888 7a97cfc4f). PRD 1.20: Jason approved draft 2 at 22:57Z ("The file looks fine other than that"; the Montage module name stays deferred). The PRD now has 118 requirements, with REQ-WS-1..8, REQ-AGENT-1..3, REQ-SYS-1..2, REQ-ACCT-1..2 and REQ-SETUP-1..2 added and REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3 amended. Builder Codex Terra xhigh declined to commit because stack-copy failed 20/1 (node-gyp missing from the test Node's npm); that failure is pre-existing on 483eccb3d, so the lead committed. Sol review APPROVE 0 (word-for-word diff against draft 2, catalog order, both requirements tests). Defect l: before, an agent that declared a store credential made the next bus start refuse, because Credentials refuses a missing store root and only issue() created it. After: start creates the root 0700 when any reference names the store; a root that exists with loose modes or as a symlink still refuses. Two new credential-verbs tests fail on the parent. Bus suite 171 files one at a time, 1713/0 (Node 26.12.1, env -i). Sol review APPROVE 0. uiorg redeployed at 7a97cfc4f, 23:15Z. Follow-up: the stack-copy node-gyp test environment. Reports: /mnt/storage/scratch/mosaic-lead/reports/{prd120-land,prd120-review,defectl-review}.md.
2026-11-10 UTC, unified workspace U1 and SYS1 (#1873; PRs #1889 d35447005 and #1890 2c6556ab1). U1, before: each org page read only its own bus and nothing read every org at once. After: /api/workspace returns projects, sessions, inbox, tasks and metrics from every org the owner holds, each row tagged with its org and each org with a state (ok, starting, down, timeout, invalid); `mosaic console restart` and `mosaic org reload <org>` send the F3 routes. Sol review CHANGES 0/2 on 2109458b3: malformed data from one org turned the whole read into a 503, and the per-org deadline left sockets and transport children running. Terra fix 0672d5fe8 validates each org's result inside its boundary and carries an absolute deadline and AbortSignal through the status check and transport; the new workspace-failures test is 0/10 on the parent and 10/0 on the fix. The lead re-pinned the webui bus.mjs hash in bus channel-proof (the deliberate signal/deadline change; nonces, argv and limits unchanged) and amended as d35447005. SYS1, before: no system-type kind. After: a system-type plug-in kind with five modules (business-management, coding, creative-writing, marketing, montage), an org enable list under plugins.system-type, owner-only project selection with an append-only project_system_types table (migration 3t-3u) and CLI verbs; the modules grant no tools yet (REQ-SYS-2 deferred). Sol review CHANGES 1/2 on a9629f332: the restore guard did not count project_system_types, the CLI channel did not admit the two project verbs, and the #811 migration-reader pin still expected 18 steps. Lead fix 2c6556ab1 (rebased on d35447005) adds the table to COUNTED and LISTED with refusal and in-run recount tests, admits both verbs with a run()-level channel test (red on the unfixed channel), and moves the pin to 19 steps ending 3t-3u; fix files 323/0 and rebase overlap files 63/0 (Node 26.12.1, one file at a time, env -i). Full gate on a9629f332 4040/5: the two stack-release pins above, and tester-reads 3/3, which also fails on 1616d1dd6 (project.mjs:95 refuses an org --tester init; follow-up). Correction to the uiorg deploy at 00:50Z: the deploy script switched the tree without the offline schema migration, so all five buses refused to start (normal startup refuses an older schema by design). The lead ran migrate-schema.mjs for the five org stores (3t to 3u, all ready), restarted the buses (201 each) and added the migration to the deploy script with stop-on-refusal. test3 refused writer-locked and stays down, as before. Reports: /mnt/storage/scratch/mosaic-lead/reports/{u1-review,u1-fix,org-sys1-build,sys1-review}.md.
+3
View File
@@ -2121,3 +2121,6 @@ are never rewritten or removed; corrections are new entries.
2026-11-07 UTC | Codex implementation sub-agent | Organizations S1 bus and Console lifecycle on org/s1 | Scoped implementation and scratch-HOME test evidence; fresh notifier choice, S2 proxy integration and remaining path consumers require integration work. Inventory, commands and limits: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. Queue refused linked worktree; no push or deployment.
2026-11-07 UTC | Claude implementation sub-agent | Organizations S6 WebUI create and guided setup on org/s6 | Settings > Organizations: template form with JSON preview, C2 create/update with if-revision, setup cards for checkout, repositories and bus; webui suite 974/975 (inherited dev-owner test failure). Report and screenshots: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s6.md. No push, merge or deployment.
2026-11-07 UTC | Codex implementation sub-agent | Organizations release fix round 1 on ui-org-r1 | Local candidate for independent review; verification and findings recorded in /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-fix-r1.md. Queue refused linked worktree; no push, merge or deployment.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | Organizations F3 fix round and landing (#1873) | PR #1884 merged a8f9f0957; review P3 fixed, P1 README corrected with follow-up #1883; 195/0 focused suite; uiorg redeployed 22:45Z.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | PRD 1.20 landing and defect l (#1873, #1886) | PR #1887 merged 03fe321b1, PR #1888 merged 7a97cfc4f; both Sol reviews APPROVE 0; bus suite 1713/0; uiorg redeployed 23:15Z; U1, U2 and U4a builds dispatched.
2026-11-10 UTC | Mos (lead, Claude Opus 5.5) | U1 and SYS1 landings (#1873) | PR #1889 merged d35447005, PR #1890 merged 2c6556ab1; both Sol reviews CHANGES, fixed (U1 by Terra, SYS1 by the lead); uiorg at 2c6556ab1 after the missed 3t-3u store migration was run; deploy script now migrates.
+4 -1
View File
@@ -311,7 +311,8 @@ scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] | stop | status [--json]
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
scripts/mosaic console stop | status
scripts/mosaic console stop | status | restart
scripts/mosaic org reload <org>
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
scripts/mosaic threads [--project <project>] [--json] [--tester]
@@ -332,6 +333,8 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start` (otherwise the Console answers `not-supervised`); it waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
selected configuration's data root. The bus stays up for reads and human
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sidebar · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app drawer">
<header class="cmdbar">
<button class="menu" aria-label="Close navigation" aria-expanded="true"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html" aria-current="page"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="crumb">Shell</div>
<div class="vh"><h1>Sidebar</h1><p class="sub">Every organization stays in view. There is no organization switcher: selecting an organization opens its dashboard, and its projects sit in the group below.</p></div>
<div class="specimen">
<figure>
<div class="mini">
<div class="grp-h" aria-expanded="false"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span></div>
<div class="collapsed-marks"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="home" aria-hidden="true">HM</span></div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
</div>
</div>
<figcaption>Organizations collapsed. The row keeps the count and the three marks, so nothing disappears from view.</figcaption>
</figure>
<figure>
<div class="mini"><div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="false">By org</span><span aria-pressed="true">Flat</span></span></div>
<a class="nav proj flat" href="#"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a>
<a class="nav proj flat" href="#" aria-current="page"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">Brand refresh</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="t">Garden plan</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">Monthly newsletter</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="t">CLI tools</span><span class="n">paused</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="t">Trip planning 2027</span><span class="n">paused</span></a>
</div></div>
<figcaption>Projects flat, sorted by activity. Each row leads with its organization's mark.</figcaption>
</figure>
<figure>
<div class="mini">
<div class="grp-h"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organization states</span></div>
<a class="nav org-row" href="03-org-dashboard.html" aria-current="page"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="dot s-offline"><b aria-hidden="true">○</b>bus not read</span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="dot s-waiting"><b aria-hidden="true">◐</b>needs you</span></a>
</div>
<figcaption>Selected, bus unreachable, and an item waiting. Status is a symbol plus a word, never colour alone.</figcaption>
</figure>
</div>
<ol class="callouts">
<li><b>The org mark</b> is a tile in the organization's theme colour with its initials (or an uploaded image), always beside the name. The type icon on the right (business, coding, personal) is the third cue the owner asked for.</li>
<li><b>Plus</b> on the Organizations row starts the organization editor (11-07 ruling 6); it is tied to <code>mosaic org create ID --from FILE</code>.</li>
<li><b>Settings</b> no longer holds an "Existing organization" select. Organization settings open from each org dashboard.</li>
<li><b>Open state</b> of each group and the By org / Flat choice are remembered per viewer.</li>
<li><b>On a phone</b> the sidebar becomes a drawer behind the menu button, with the same groups.</li>
</ol>
</main>
</div>
</body>
</html>
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Dashboard</h1><div class="right"><span class="seg" role="group" aria-label="Period"><span aria-pressed="false">Today</span><span aria-pressed="true">7 days</span><span aria-pressed="false">30 days</span></span></div>
<p class="sub">All 3 organizations and 10 projects. Totals add up every organization you belong to; each tile breaks them down by organization.</p></div>
<div class="grid g5 keep2">
<div class="tile"><span class="label">Needs you</span><span class="big">3<small>items</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.333"></i><i data-org="forge" style="flex:0.333"></i><i data-org="home" style="flex:0.333"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>1</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>1</span></div><span class="delta">oldest 52 min</span></div>
<div class="tile"><span class="label">Sessions</span><span class="big">7<small>4 working</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.429"></i><i data-org="forge" style="flex:0.286"></i><i data-org="home" style="flex:0.143"></i><i style="background:var(--muted);flex:0.143"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>3</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>2</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>1</span><span><span class="av all " aria-hidden="true" title="Assistant, all organizations"><svg class="mosaic-logo" viewBox="0 0 36 36" style="width:70%;height:auto" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg></span>1</span></div><span class="delta">1 waiting · 1 error · 1 idle</span></div>
<div class="tile"><span class="label">Open tasks</span><span class="big">10</span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.500"></i><i data-org="forge" style="flex:0.300"></i><i data-org="home" style="flex:0.200"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>5</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>3</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>2</span></div><span class="delta"><b>2</b> done this week</span></div>
<div class="tile"><span class="label">Projects</span><span class="big">10<small>8 active</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.400"></i><i data-org="forge" style="flex:0.300"></i><i data-org="home" style="flex:0.300"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>4</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>3</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>3</span></div><span class="delta">2 paused</span></div>
<div class="tile"><span class="label">Model spend</span><span class="big">$38.40<small>7 days</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.549"></i><i data-org="forge" style="flex:0.406"></i><i data-org="home" style="flex:0.044"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>$21.10</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>$15.60</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>$1.70</span></div><span class="delta">of $110 in weekly limits</span></div>
</div>
<div class="split" style="margin-top:4px">
<section>
<div class="sec-h"><h2>Activity across organizations</h2><span class="note">newest first, every bus</span><a href="#">All activity</a></div>
<ol class="trail"><li><time>14:32</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>coder</b> pushed 3 commits on #41 Rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>14:28</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>copywriter</b> saved V-112 Launch email, draft 2 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>14:21</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>reviewer</b> stopped: model provider timed out <span class="src">· Forge · Web app</span></span></li><li><time>14:15</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>editor</b> asked you: keep the revised ending? <span class="src">· Distinctly Digital · The Lantern Coast</span></span></li><li><time>14:02</time><span class="av " data-org="home" aria-hidden="true">HM</span><span class="what"><b>assistant</b> flagged the insurance renewal <span class="src">· Home · Household admin</span></span></li><li><time>13:55</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>montage-producer</b> rendered teaser cut 3 of 4 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>13:40</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>chief</b> raised a gated decision: merge the rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>13:12</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>chief</b> created V-120 Logo shortlist in Vikunja <span class="src">· Distinctly Digital · Brand refresh</span></span></li><li><time>12:58</time><span class="av " data-org="home" aria-hidden="true">HM</span><span class="what"><b>you</b> closed #9 Order seed potatoes <span class="src">· Home · Garden plan</span></span></li></ol>
<div class="sec-h"><h2>Tasks by state</h2><span class="note">all trackers</span><a href="06-tasks.html">Open tasks</a></div>
<div class="panel tight states">
<div class="srow"><span class="sl">To do</span><span class="sn tnum">5</span><div class="bar" style="width:90%" aria-hidden="true"><i data-org="ddigital" style="flex:2"></i><i data-org="forge" style="flex:1"></i><i data-org="home" style="flex:2"></i></div><span class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>2</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>2</span></span></div><div class="srow"><span class="sl">In progress</span><span class="sn tnum">3</span><div class="bar" style="width:54%" aria-hidden="true"><i data-org="ddigital" style="flex:2"></i><i data-org="forge" style="flex:1"></i></div><span class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>2</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span></span></div><div class="srow"><span class="sl">Review</span><span class="sn tnum">2</span><div class="bar" style="width:36%" aria-hidden="true"><i data-org="ddigital" style="flex:1"></i><i data-org="forge" style="flex:1"></i></div><span class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>1</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span></span></div><div class="srow"><span class="sl">Done this week</span><span class="sn tnum">2</span><div class="bar" style="width:36%" aria-hidden="true"><i data-org="forge" style="flex:1"></i><i data-org="home" style="flex:1"></i></div><span class="by"><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>1</span></span></div>
</div>
</section>
<section>
<div class="sec-h"><h2>Needs you</h2><a href="05-inbox.html">Inbox</a></div>
<ul class="needs"><li class="edge" data-org="forge"><span class="av md" data-org="forge" aria-hidden="true">FG</span><span class="q">Merge the rate limiter branch into main?</span><span class="src">Forge · API gateway · chief</span><span class="chip gated">gated</span></li><li class="edge" data-org="ddigital"><span class="av md" data-org="ddigital" aria-hidden="true">DD</span><span class="q">Chapter 7: keep the revised ending?</span><span class="src">Distinctly Digital · The Lantern Coast · editor</span><span class="chip within">within-role</span></li><li class="edge" data-org="home"><span class="av md" data-org="home" aria-hidden="true">HM</span><span class="q">Home insurance renews in 12 days. Compare the two quotes now?</span><span class="src">Home · Household admin · assistant</span><span class="chip routine">routine</span></li></ul>
<div class="sec-h"><h2>Sessions now</h2><a href="08-agents.html">Agents</a></div>
<div class="tbl"><table class="sess"><thead><tr><th>Agent and project</th><th>Status</th></tr></thead><tbody>
<tr><td class="who"><span class="orgref"><span class="av all xs" aria-hidden="true" title="Assistant, all organizations"><svg class="mosaic-logo" viewBox="0 0 36 36" style="width:70%;height:auto" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg></span>assistant</span><span class="muted"> · All organizations</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">with you</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>coder</span><span class="muted"> · API gateway</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">65 min</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>copywriter</span><span class="muted"> · Spring launch</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">38 min</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>montage-producer</span><span class="muted"> · Spring launch</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">72 min</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>editor</span><span class="muted"> · The Lantern Coast</span></td><td class="st"><span class="dot s-waiting"><b aria-hidden="true">◐</b>waiting</span> <span class="muted">on you</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>reviewer</span><span class="muted"> · Web app</span></td><td class="st"><span class="dot s-error"><b aria-hidden="true">▲</b>error</span> <span class="muted">timeout</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="home" aria-hidden="true">HM</span>planner</span><span class="muted"> · Household admin</span></td><td class="st"><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span> <span class="muted">since 09:10</span></td></tr>
</tbody></table></div>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Distinctly Digital · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html" aria-current="page"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="crumb"><a href="#">Organizations</a> › Distinctly Digital</div>
<div class="orghead" data-org="ddigital">
<span class="av lg" data-org="ddigital" aria-hidden="true">DD</span>
<h1>Distinctly Digital <svg class="i" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></h1>
<div class="right">
<span class="btn" title="mosaic bus restart ddigital"><svg class="i" aria-hidden="true"><use href="#i-restart"/></svg>Restart bus</span>
<span class="btn"><svg class="i" aria-hidden="true"><use href="#i-doc"/></svg>Reload organization file</span>
<a class="btn pri" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-split"/></svg>Open in a pane</a>
</div>
<div class="meta"><span>Business</span><span>id <code>ddigital</code></span><span>Tracker: Vikunja</span><span><span class="dot s-working"><b aria-hidden="true">●</b>bus read 4 s ago</span></span><span>Restart and reload are trailed human actions; no agent can take them.</span></div>
</div>
<div class="typechips chips"><span class="label" style="margin-right:4px">System types</span><span class="chip outline">Coding</span><span class="chip outline">Creative writing</span><span class="chip outline">Marketing</span><span class="chip outline">Mosaic Montage</span><span class="chip outline">Business management</span><span class="note">Each project picks from these; none is required.</span></div>
<div class="docrow chips"><span class="label" style="margin-right:4px">Docs</span><a class="doc" href="#" title="org"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>North star</a><a class="doc" href="#" title="org"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Brand voice guide</a><a class="doc" href="#" title="Spring launch"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Launch plan<small>Spring launch</small></a><a class="doc" href="#" title="The Lantern Coast"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Series bible<small>The Lantern Coast</small></a><a class="doc" href="#" title="org"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Operating notes</a><a href="#" class="more">All docs</a></div>
<div class="split" style="margin-top:6px">
<section>
<div class="sec-h"><h2>KPIs</h2><span class="note">defined in the organization file</span><a href="#">Edit KPIs</a></div>
<div class="grid g4 keep2">
<div class="tile"><span class="label">Subscribers</span><span class="big">2,480</span><span class="delta"><b>+6.2%</b> in 30 days · goal 3,000</span></div>
<div class="tile"><span class="label">Launch pieces</span><span class="big">7<small>of 12</small></span><div class="bar"><i data-org="ddigital" style="flex:7"></i><i style="flex:5;background:transparent"></i></div></div>
<div class="tile"><span class="label">Novel draft</span><span class="big">61,200<small>words</small></span><span class="delta">target 90,000 · 68%</span></div>
<div class="tile"><span class="label">Tasks closed</span><span class="big">18</span><span class="delta">30 days · <b>+4</b> on last month</span></div>
</div>
<div class="sec-h"><h2>Metrics</h2><span class="note">measured by the bus</span></div>
<div class="grid g4 keep2 metrics">
<div class="tile"><span class="label">Spend this week</span><span class="big">$21.10<small>of $60</small></span><div class="bar"><i data-org="ddigital" style="flex:21.1"></i><i style="flex:38.9;background:transparent"></i></div></div>
<div class="tile"><span class="label">Agent hours</span><span class="big">31.5<small>7 days</small></span></div>
<div class="tile"><span class="label">Decision wait</span><span class="big">22<small>min median</small></span></div>
<div class="tile"><span class="label">Open tasks</span><span class="big">5</span><span class="delta">2 in progress · 1 review</span></div>
</div>
<div class="sec-h"><h2>Active agent sessions</h2><span class="note">2 working, 1 waiting on you</span><a href="08-agents.html">All agents</a></div>
<div class="tbl"><table><thead><tr><th>Agent</th><th>Project and task</th><th class="hide-m">Model</th><th>Status</th></tr></thead><tbody>
<tr><td class="t1">copywriter</td><td>Spring launch<span class="t2">V-112 Launch email, draft 2</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">38 min</span></td></tr>
<tr><td class="t1">montage-producer</td><td>Spring launch<span class="t2">V-118 Teaser reel, 30 s cut</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">1 h 12 min</span></td></tr>
<tr><td class="t1">editor</td><td>The Lantern Coast<span class="t2">V-97 Chapter 7 revision</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td><td><span class="dot s-waiting"><b aria-hidden="true">◐</b>waiting on you</span></td></tr>
</tbody></table></div>
</section>
<section>
<div class="sec-h"><h2>Settings</h2><span class="note">every setting, with where its value comes from</span></div>
<ul class="cfg one"><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg><span class="k">Identity</span><span class="v">Distinctly Digital · ddigital · Business</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-flag"/></svg><span class="k">Appearance</span><span class="v">Rosewood theme · DD avatar</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-runs"/></svg><span class="k">System types</span><span class="v">5 of 5 installed enabled</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="k">Projects</span><span class="v">4 projects · 3 with a remote, 1 local only</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="k">Agents and roles</span><span class="v">5 org · 2 project · 3 from your root</span><span class="src-tag">org + root</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg><span class="k">Models and providers</span><span class="v">Your shared proxy · editor runs opus-5.5</span><span class="src-tag">override</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="k">Task tracker</span><span class="v">Vikunja module · project DD</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="k">Channels</span><span class="v">Discord off · daily email digest</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-ledger"/></svg><span class="k">Launch limits</span><span class="v">4 sessions at once · $60 a week</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-queue"/></svg><span class="k">Routing and arbiters</span><span class="v">chief routes · editor arbitrates copy</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="k">Members and invites</span><span class="v">You (owner) · invites arrive in Phase M</span><span class="src-tag">not built</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="#i-restart"/></svg><span class="k">Bus</span><span class="v">Running · local-only checkout</span><span class="src-tag">system</span><a class="go" href="#">Open</a></li><li class="danger"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg><span class="k">Archive or delete</span><span class="v">Removes this organization's tasks and data only</span><span class="src-tag">danger</span><a class="go" href="#">Open</a></li></ul>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,164 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Projects · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Projects</h1><div class="right"><span class="btn"><svg class="i" aria-hidden="true"><use href="#i-plus"/></svg>New project</span></div>
<p class="sub">10 projects in 3 organizations, sorted by latest activity. Every row names its organization by mark, colour edge and type icon.</p></div>
<div class="toolbar">
<span class="search"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg>Filter by name</span>
<div class="chips" role="group" aria-label="Organization"><span class="f plain" aria-pressed="true">All <span class="n">10</span></span><span class="f" aria-pressed="false"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="n">4</span></span><span class="f" aria-pressed="false"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="n">3</span></span><span class="f" aria-pressed="false"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="n">3</span></span></div>
<span class="sel"><span class="label">Status</span>Active, paused<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span>
<div class="right"><span class="label hide-m">Sort</span><span class="seg" role="group" aria-label="Sort"><span aria-pressed="true">Activity</span><span aria-pressed="false">Name</span><span aria-pressed="false">Org</span><span aria-pressed="false">Status</span></span></div>
</div>
<div class="tbl"><table class="projects">
<thead><tr><th class="hide-m">Organization</th><th>Project</th><th>Status</th><th class="hide-m">System types</th><th class="hide-m">Sessions</th><th class="num hide-m">Open tasks</th><th class="num">Needs you</th><th class="sorted hide-m">Last activity<span class="ar">↓</span></th><th class="hide-m">Git</th></tr></thead>
<tbody>
<tr class="edge" data-org="forge">
<td class="hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="forge" aria-hidden="true">FG</span>API gateway</span><span class="t2 mono hide-m">api-gateway</span><span class="t2 show-m">2 min ago · coder pushed 3 commits on #41</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Coding</span></td>
<td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>1 working</span></td>
<td class="num hide-m">2</td>
<td class="num"><span class="n hot">1</span></td>
<td class="hide-m">2 min ago<span class="t2">coder pushed 3 commits on #41</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>Spring launch</span><span class="t2 mono hide-m">spring-launch</span><span class="t2 show-m">4 min ago · copywriter saved draft 2</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Marketing</span> <span class="chip outline">Creative writing</span> <span class="chip outline">Mosaic Montage</span></td>
<td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>2 working</span></td>
<td class="num hide-m">2</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">4 min ago<span class="t2">copywriter saved draft 2</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="forge">
<td class="hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="forge" aria-hidden="true">FG</span>Web app</span><span class="t2 mono hide-m">webapp</span><span class="t2 show-m">11 min ago · reviewer stopped: provider timeout</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Coding</span></td>
<td class="hide-m"><span class="dot s-error"><b aria-hidden="true">▲</b>1 error</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">11 min ago<span class="t2">reviewer stopped: provider timeout</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>The Lantern Coast</span><span class="t2 mono hide-m">novel-draft</span><span class="t2 show-m">17 min ago · editor asked about chapter 7</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Creative writing</span></td>
<td class="hide-m"><span class="dot s-waiting"><b aria-hidden="true">◐</b>1 waiting</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="n hot">1</span></td>
<td class="hide-m">17 min ago<span class="t2">editor asked about chapter 7</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr><tr class="edge" data-org="home">
<td class="hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="home" aria-hidden="true">HM</span>Household admin</span><span class="t2 mono hide-m">household</span><span class="t2 show-m">30 min ago · assistant flagged a renewal</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Business management</span></td>
<td class="hide-m"><span class="dot s-idle"><b aria-hidden="true">–</b>1 idle</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="n hot">1</span></td>
<td class="hide-m">30 min ago<span class="t2">assistant flagged a renewal</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>Brand refresh</span><span class="t2 mono hide-m">brand-refresh</span><span class="t2 show-m">1 h ago · chief created V-120</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Marketing</span> <span class="chip outline">Mosaic Montage</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">1 h ago<span class="t2">chief created V-120</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="home">
<td class="hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="home" aria-hidden="true">HM</span>Garden plan</span><span class="t2 mono hide-m">garden</span><span class="t2 show-m">1 h ago · #9 marked done</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">0</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">1 h ago<span class="t2">#9 marked done</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>Monthly newsletter</span><span class="t2 mono hide-m">newsletter</span><span class="t2 show-m">yesterday · V-121 created</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Marketing</span> <span class="chip outline">Creative writing</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">yesterday<span class="t2">V-121 created</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge dim" data-org="forge">
<td class="hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="forge" aria-hidden="true">FG</span>CLI tools</span><span class="t2 mono hide-m">cli-tools</span><span class="t2 show-m">12 days ago · #29 done</span></td>
<td><span class="dot s-idle"><b aria-hidden="true">–</b>paused</span></td>
<td class="hide-m"><span class="chip outline">Coding</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">0</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">12 days ago<span class="t2">#29 done</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge dim" data-org="home">
<td class="hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="home" aria-hidden="true">HM</span>Trip planning 2027</span><span class="t2 mono hide-m">travel</span><span class="t2 show-m">3 weeks ago · #14 created</span></td>
<td><span class="dot s-idle"><b aria-hidden="true">–</b>paused</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">3 weeks ago<span class="t2">#14 created</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr>
</tbody></table></div>
<p class="note" style="margin-top:10px">Sorting by Org groups the rows under each organization's header. Paused projects stay listed, dimmed, and the Status filter hides them.</p>
</main>
</div>
</body>
</html>
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Inbox · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app with-insp">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Inbox</h1>
<p class="sub">3 items need you, from 3 organizations. Each row names where it came from. Seen is not resolution: an item leaves only when it is answered.</p></div>
<div class="toolbar">
<div class="chips" role="group" aria-label="Organization"><span class="f plain" aria-pressed="true">All <span class="n">3</span></span><span class="f" aria-pressed="false"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="n">1</span></span><span class="f" aria-pressed="false"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="n">1</span></span><span class="f" aria-pressed="false"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="n">1</span></span></div>
<div class="right"><span class="seg" role="group" aria-label="State"><span aria-pressed="true">Needs you</span><span aria-pressed="false">Seen</span><span aria-pressed="false">Answered</span></span></div>
</div>
<div class="tbl"><table class="ibx">
<thead><tr><th class="hide-m">Source</th><th>Needs</th><th class="hide-m">Class</th><th class="num">Waiting</th></tr></thead>
<tbody>
<tr class="row edge" data-org="forge" aria-selected="true"><td class="src hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="via">API gateway · chief</span></td><td><span class="msrc show-m"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge · API gateway · chief</span><span class="q">Merge the rate limiter branch into main?</span><span class="via">decision 4f2a91 · seen 14:05</span><span class="show-m"><span class="chip gated">gated</span></span></td><td class="hide-m"><span class="chip gated">gated</span></td><td class="num">52 min</td></tr><tr class="row edge" data-org="ddigital"><td class="src hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="via">The Lantern Coast · editor session</span></td><td><span class="msrc show-m"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital · The Lantern Coast · editor</span><span class="q">Chapter 7: keep the revised ending?</span><span class="via">decision 7c03e2 · not seen</span><span class="show-m"><span class="chip within">within-role</span></span></td><td class="hide-m"><span class="chip within">within-role</span></td><td class="num">17 min</td></tr><tr class="row edge" data-org="home"><td class="src hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home</span><span class="via">Household admin · assistant</span></td><td><span class="msrc show-m"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home · Household admin · assistant</span><span class="q">Home insurance renews in 12 days. Compare the two quotes now?</span><span class="via">decision b19d40 · not seen</span><span class="show-m"><span class="chip routine">routine</span></span></td><td class="hide-m"><span class="chip routine">routine</span></td><td class="num">30 min</td></tr>
</tbody></table></div>
<div class="sec-h"><h2>Answered today</h2><span class="note">kept for 24 hours, then in the trail</span></div>
<div class="tbl"><table class="ibx"><tbody>
<tr class="dim"><td class="src hide-m"><span class="orgref quiet"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="via">Spring launch · copywriter</span></td><td><span class="msrc show-m"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital · Spring launch</span>Use the shorter subject line?<span class="via">answered 11:40 by you · option 2</span></td><td class="num">done</td></tr>
<tr class="dim"><td class="src hide-m"><span class="orgref quiet"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="via">Web app · reviewer session</span></td><td><span class="msrc show-m"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge · Web app</span>Retry with the fallback model?<span class="via">expired 10:02 · no answer needed</span></td><td class="num">expired</td></tr>
</tbody></table></div>
</main>
<aside class="insp" aria-label="Decision" data-org="forge">
<header><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="label">Decision 4f2a91</span><span class="chip gated">gated</span><span class="x" aria-label="Close"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></span></header>
<div class="body">
<h3>Merge the rate limiter branch into main?</h3>
<div class="auth">Forge · API gateway · raised by <b>chief</b> at 13:40. Only you can answer a gated decision.</div>
<p class="note">Branch <code>rate-limiter-41</code> passes the suite (312 tests). The review is missing: the reviewer session stopped on a provider timeout at 14:21.</p>
<ul class="opts">
<li class="rec"><div class="row"><span class="k">1</span><b>Merge after a fresh review</b><span class="chip ok">chief recommends</span></div><span class="note">Restart the reviewer on the fallback model, then merge if it passes.</span><div class="row"><span class="btn pri">Answer 1…</span></div></li>
<li><div class="row"><span class="k">2</span><b>Merge now</b></div><span class="note">Skips review. The trail records the waiver.</span><div class="row"><span class="btn">Answer 2…</span></div></li>
<li><div class="row"><span class="k">3</span><b>Hold until Monday</b></div><div class="row"><span class="btn">Answer 3…</span></div></li>
</ul>
<div><span class="label">Answer from a terminal</span>
<div class="cmd" style="margin-top:6px"><code>mosaic decide 4f2a91 1 --org forge</code><span class="btn" title="Copy"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-copy"/></svg></span></div>
<p class="note" style="margin-top:6px">The inbox mixes organizations, so the copied command always names the organization's bus.</p></div>
<div><span class="label">History</span>
<ol class="hist" style="margin-top:6px"><li><time>13:40</time>chief raised it on the Forge bus</li><li><time>13:41</time>reached your inbox</li><li><time>14:05</time>seen by you (not an answer)</li></ol></div>
</div>
</aside>
</div>
</body>
</html>
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tasks · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Tasks</h1><div class="right"><span class="seg" role="group" aria-label="Layout"><span aria-pressed="true" class="kb">Kanban</span><span aria-pressed="false" class="ls">List</span></span><span class="btn"><svg class="i" aria-hidden="true"><use href="#i-plus"/></svg>New task</span></div>
<p class="sub">12 tasks from 3 organizations. Each organization keeps its own task store; this view reads all of them and writes each change back to the store it came from.</p></div>
<div class="trackers"><span class="label">Trackers</span><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital <span class="trk vik">Vikunja</span></span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge <span class="trk">built-in</span></span><span><span class="av " data-org="home" aria-hidden="true">HM</span>Home <span class="trk">built-in</span></span><span class="note">Ids keep their tracker's form: V-112 is Vikunja, #41 is built in. Archiving an organization removes only its tasks.</span></div>
<div class="toolbar">
<div class="chips" role="group" aria-label="Organization"><span class="f plain" aria-pressed="true">All <span class="n">12</span></span><span class="f" aria-pressed="false"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="n">5</span></span><span class="f" aria-pressed="false"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="n">4</span></span><span class="f" aria-pressed="false"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="n">3</span></span></div>
<span class="sel"><span class="label">Project</span>All 10<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span>
<span class="sel"><span class="label">Assignee</span>Anyone<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span>
<div class="right"><span class="search"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg>Find a task</span></div>
</div>
<div class="board">
<section class="col"><div class="col-h"><h3>To do</h3><span class="n">5</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="av xs" data-org="home" aria-hidden="true">HM</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#44</span><span class="trk">built-in</span></div>
<div class="tt">Settings page: model picker</div>
<div class="bot"><span>Web app</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-120</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Logo shortlist</div>
<div class="bot"><span>Brand refresh</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-121</span><span class="trk vik">Vikunja</span></div>
<div class="tt">November issue outline</div>
<div class="bot"><span>Monthly newsletter</span></div>
</article><article class="kcard edge you" data-org="home">
<div class="top"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="id">#12</span><span class="trk">built-in</span></div>
<div class="tt">Insurance renewal comparison</div>
<div class="bot"><span>Household admin</span><span class="chip warn">needs you</span></div>
</article><article class="kcard edge" data-org="home">
<div class="top"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="id">#14</span><span class="trk">built-in</span></div>
<div class="tt">Shortlist ferry dates</div>
<div class="bot"><span>Trip planning 2027</span></div>
</article></section>
<section class="col"><div class="col-h"><h3>In progress</h3><span class="n">3</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#41</span><span class="trk">built-in</span></div>
<div class="tt">Rate limiter: per-key buckets</div>
<div class="bot"><span>API gateway</span><span class="dot s-working"><b aria-hidden="true">●</b>coder</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-112</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Launch email, draft 2</div>
<div class="bot"><span>Spring launch</span><span class="dot s-working"><b aria-hidden="true">●</b>copywriter</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-118</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Teaser reel, 30 s cut</div>
<div class="bot"><span>Spring launch</span><span class="dot s-working"><b aria-hidden="true">●</b>montage-producer</span></div>
</article></section>
<section class="col"><div class="col-h"><h3>Review</h3><span class="n">2</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#38</span><span class="trk">built-in</span></div>
<div class="tt">Load test at 2k rps</div>
<div class="bot"><span>API gateway</span><span class="dot s-idle"><b aria-hidden="true">–</b>reviewer</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-97</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Chapter 7 revision</div>
<div class="bot"><span>The Lantern Coast</span><span class="dot s-waiting"><b aria-hidden="true">◐</b>editor, on you</span></div>
</article></section>
<section class="col"><div class="col-h"><h3>Done</h3><span class="n">2</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="home" aria-hidden="true">HM</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#29</span><span class="trk">built-in</span></div>
<div class="tt">zsh completion</div>
<div class="bot"><span>CLI tools</span></div>
</article><article class="kcard edge" data-org="home">
<div class="top"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="id">#9</span><span class="trk">built-in</span></div>
<div class="tt">Order seed potatoes</div>
<div class="bot"><span>Garden plan</span></div>
</article></section>
</div>
<section class="tasklist">
<div class="sec-h"><h2>List layout</h2><span class="note">same filters, one row per task, grouped by state</span></div>
<div class="tbl"><table><thead><tr><th>Org</th><th>Id</th><th>Task</th><th class="hide-m">Project</th><th>State</th><th class="hide-m">Assignee</th></tr></thead><tbody>
<tr class="edge" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#44<span class="t2">built-in</span></td><td><span class="t1">Settings page: model picker</span><span class="t2 show-m">Web app</span></td><td class="hide-m">Web app</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-120<span class="t2">Vikunja</span></td><td><span class="t1">Logo shortlist</span><span class="t2 show-m">Brand refresh</span></td><td class="hide-m">Brand refresh</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-121<span class="t2">Vikunja</span></td><td><span class="t1">November issue outline</span><span class="t2 show-m">Monthly newsletter</span></td><td class="hide-m">Monthly newsletter</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="home"><td><span class="av " data-org="home" aria-hidden="true">HM</span></td><td class="mono">#12<span class="t2">built-in</span></td><td><span class="t1">Insurance renewal comparison</span><span class="t2 show-m">Household admin</span></td><td class="hide-m">Household admin</td><td>To do</td><td class="hide-m"><span class="chip warn">you</span></td></tr><tr class="edge" data-org="home"><td><span class="av " data-org="home" aria-hidden="true">HM</span></td><td class="mono">#14<span class="t2">built-in</span></td><td><span class="t1">Shortlist ferry dates</span><span class="t2 show-m">Trip planning 2027</span></td><td class="hide-m">Trip planning 2027</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#41<span class="t2">built-in</span></td><td><span class="t1">Rate limiter: per-key buckets</span><span class="t2 show-m">API gateway</span></td><td class="hide-m">API gateway</td><td>In progress</td><td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>coder</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-112<span class="t2">Vikunja</span></td><td><span class="t1">Launch email, draft 2</span><span class="t2 show-m">Spring launch</span></td><td class="hide-m">Spring launch</td><td>In progress</td><td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>copywriter</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-118<span class="t2">Vikunja</span></td><td><span class="t1">Teaser reel, 30 s cut</span><span class="t2 show-m">Spring launch</span></td><td class="hide-m">Spring launch</td><td>In progress</td><td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>montage-producer</span></td></tr><tr class="edge" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#38<span class="t2">built-in</span></td><td><span class="t1">Load test at 2k rps</span><span class="t2 show-m">API gateway</span></td><td class="hide-m">API gateway</td><td>Review</td><td class="hide-m"><span class="dot s-idle"><b aria-hidden="true">–</b>reviewer</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-97<span class="t2">Vikunja</span></td><td><span class="t1">Chapter 7 revision</span><span class="t2 show-m">The Lantern Coast</span></td><td class="hide-m">The Lantern Coast</td><td>Review</td><td class="hide-m"><span class="dot s-waiting"><b aria-hidden="true">◐</b>editor</span></td></tr><tr class="edge dim" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#29<span class="t2">built-in</span></td><td><span class="t1">zsh completion</span><span class="t2 show-m">CLI tools</span></td><td class="hide-m">CLI tools</td><td>Done</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge dim" data-org="home"><td><span class="av " data-org="home" aria-hidden="true">HM</span></td><td class="mono">#9<span class="t2">built-in</span></td><td><span class="t1">Order seed potatoes</span><span class="t2 show-m">Garden plan</span></td><td class="hide-m">Garden plan</td><td>Done</td><td class="hide-m"><span class="muted">nobody</span></td></tr>
</tbody></table></div>
</section>
</main>
</div>
</body>
</html>
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Side by side · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main flush">
<div class="panebar"><h1>Side by side</h1><span class="note">2 panes · each pane reads its own organization's bus and keeps its own state</span>
<span class="seg pane-tabs" role="group" aria-label="Shown pane"><span aria-pressed="true"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span> Spring launch</span><span aria-pressed="false"><span class="av xs" data-org="forge" aria-hidden="true">FG</span> API gateway</span></span>
<span class="btn hide-m" style="margin-left:auto"><svg class="i" aria-hidden="true"><use href="#i-plus"/></svg>Add pane</span></div>
<div class="panes">
<section class="pane" data-org="ddigital">
<div class="pane-h"><span class="av md" data-org="ddigital" aria-hidden="true">DD</span><span class="ttl">Spring launch<small>Distinctly Digital</small></span><span class="tools"><span title="Open in full view"><svg class="i sm" aria-hidden="true"><use href="#i-split"/></svg></span><span title="Close pane"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></span></span></div>
<div class="pane-body">
<div class="subtabs"><span aria-current="page">Overview</span><span>Tasks</span><span>Sessions</span><span>Docs</span></div>
<div class="grid g3 mini-tiles"><div class="tile"><span class="label">Open tasks</span><span class="big">2</span></div><div class="tile"><span class="label">Sessions</span><span class="big">2<small>working</small></span></div><div class="tile"><span class="label">Needs you</span><span class="big">0</span></div></div>
<div class="sec-h"><h3>copywriter · live</h3><span class="note">V-112 Launch email, draft 2</span></div>
<pre class="term"><span class="m">14:26</span> read brand-voice.md, launch-plan.md
<span class="m">14:27</span> drafted subject lines (3), kept the shortest
<span class="m">14:28</span> saved drafts/launch-email-v2.md 182 words
<span class="m">14:29</span> next: tighten the call to action
<span class="c">▍</span></pre>
<div class="sec-h"><h3>montage-producer · live</h3><span class="note">V-118 Teaser reel</span></div>
<pre class="term"><span class="m">13:55</span> rendered cut 3 of 4 00:30 1080x1920
<span class="m">14:10</span> waiting on the music bed licence check
<span class="c">▍</span></pre>
<div class="sec-h"><h3>Activity</h3></div>
<ol class="trail"><li><time>14:28</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>copywriter</b> saved V-112 Launch email, draft 2 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>13:55</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>montage-producer</b> rendered teaser cut 3 of 4 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>11:20</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>chief</b> updated the launch plan <span class="src">· Distinctly Digital · Spring launch</span></span></li></ol></div>
</section>
<section class="pane alt" data-org="forge">
<div class="pane-h"><span class="av md" data-org="forge" aria-hidden="true">FG</span><span class="ttl">API gateway<small>Forge</small></span><span class="tools"><span title="Open in full view"><svg class="i sm" aria-hidden="true"><use href="#i-split"/></svg></span><span title="Close pane"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></span></span></div>
<div class="pane-body">
<div class="subtabs"><span aria-current="page">Overview</span><span>Tasks</span><span>Sessions</span><span>Docs</span></div>
<div class="grid g3 mini-tiles"><div class="tile"><span class="label">Open tasks</span><span class="big">2</span></div><div class="tile"><span class="label">Sessions</span><span class="big">1<small>working</small></span></div><div class="tile"><span class="label">Needs you</span><span class="big">1<small>gated</small></span></div></div>
<ul class="needs" style="margin-top:12px"><li class="edge" data-org="forge"><span class="av md" data-org="forge" aria-hidden="true">FG</span><span class="q">Merge the rate limiter branch into main?</span><span class="src">chief · 52 min · <a href="05-inbox.html">Open in inbox</a></span><span class="chip gated">gated</span></li></ul>
<div class="sec-h"><h3>coder · live</h3><span class="note">#41 Rate limiter: per-key buckets</span></div>
<pre class="term"><span class="m">14:30</span> npm test 312 passed, 0 failed
<span class="m">14:31</span> bench: 2,140 rps at p99 41 ms
<span class="m">14:32</span> pushed 3 commits to rate-limiter-41
<span class="m">14:32</span> waiting for review
<span class="c">▍</span></pre>
<div class="sec-h"><h3>Activity</h3></div>
<ol class="trail"><li><time>14:32</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>coder</b> pushed 3 commits on #41 Rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>13:40</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>chief</b> raised a gated decision: merge the rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>12:40</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>load-tester</b> finished run 7 of 7 <span class="src">· Forge · API gateway</span></span></li></ol></div>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Agents · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Agents</h1><div class="right"><span class="btn"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg>Edit root agents</span></div>
<p class="sub">Agents are defined at three levels. A project can use the union of your root agents, its organization's agents and its own. Every agent except the assistant works inside one organization.</p></div>
<div class="span-band"><span class="av all md" aria-hidden="true" title="Assistant, all organizations"><svg class="mosaic-logo" viewBox="0 0 36 36" style="width:70%;height:auto" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg></span><b>assistant</b><span class="lvl root">root</span><span class="dot s-working"><b aria-hidden="true">●</b>working</span><span>Works across</span><span class="orgs"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="av " data-org="home" aria-hidden="true">HM</span></span><span class="muted">One shared memory. Talks with you and hands each organization's work to its chief; it does not direct agents, create tasks or launch sessions.</span></div>
<div class="levels" style="margin-top:12px">
<div class="lh hide-m"></div><div class="oh hide-m" data-org="ddigital"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</div><div class="oh hide-m" data-org="forge"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge</div><div class="oh hide-m" data-org="home"><span class="av " data-org="home" aria-hidden="true">HM</span>Home</div>
<div class="lh"><span class="lvl root">root</span><small>Your root configuration. Only you change it. Each organization runs its own copy, with memory, files and credentials kept in that organization.</small></div>
<div data-org="ddigital"><span class="mlabel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>reviewer <small>copy</small></span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>researcher <small>copy</small></span></div>
<div data-org="forge"><span class="mlabel"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="agent "><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span>reviewer <small>copy</small></span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>researcher <small>copy</small></span></div>
<div data-org="home"><span class="mlabel"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>researcher <small>copy</small></span><span class="note" style="display:block;margin-top:4px">reviewer turned off for Home</span></div>
<div class="lh"><span class="lvl org">org</span><small>Defined in the organization file. The chief hires and fires here; you can override any setting.</small></div>
<div data-org="ddigital"><span class="mlabel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="agent cos"><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>chief</span><span class="agent "><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span>copywriter</span><span class="agent "><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span>editor</span><span class="agent "><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span>montage-producer</span></div>
<div data-org="forge"><span class="mlabel"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="agent cos"><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>chief</span><span class="agent "><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span>coder</span></div>
<div data-org="home"><span class="mlabel"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home</span><span class="agent cos"><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>chief</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>planner</span></div>
<div class="lh last"><span class="lvl proj">project</span><small>Defined in one project and available only there. The chief hires and fires here too.</small></div>
<div class="last" data-org="ddigital"><span class="mlabel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>social-scheduler <small>Spring launch</small></span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>continuity-checker <small>The Lantern Coast</small></span></div>
<div class="last" data-org="forge"><span class="mlabel"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>load-tester <small>API gateway</small></span></div>
<div class="last" data-org="home"><span class="mlabel"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home</span><span class="muted">none</span></div>
</div>
<div class="split" style="margin-top:4px">
<section>
<div class="sec-h"><h2>Available in a project</h2><span class="sel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Spring launch<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span></div>
<div class="tbl"><table><thead><tr><th>Agent</th><th>Level</th><th class="hide-m">Defined in</th><th class="hide-m">Memory</th><th>Status</th><th class="hide-m">Model</th></tr></thead><tbody>
<tr><td class="t1">assistant</td><td><span class="lvl root">root</span></td><td class="hide-m">Your root configuration</td><td class="hide-m">Shared across all organizations</td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">reviewer</td><td><span class="lvl root">root</span></td><td class="hide-m">Your root configuration</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">researcher</td><td><span class="lvl root">root</span></td><td class="hide-m">Your root configuration</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">chief</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">copywriter</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">editor</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-waiting"><b aria-hidden="true">◐</b>waiting</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">montage-producer</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">social-scheduler</td><td><span class="lvl proj">project</span></td><td class="hide-m">Spring launch project</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">haiku-5.5 <a href="#">Change</a></td></tr>
</tbody></table></div>
<p class="note" style="margin-top:8px">Not available here: continuity-checker (The Lantern Coast only) and every Forge or Home agent.</p>
</section>
<section>
<div class="sec-h"><h2>Who changes what</h2></div>
<div class="panel tight rules">
<p><span class="lvl root">root</span> You add, change and remove root agents. The chief cannot.</p>
<p><span class="lvl org">org</span> <span class="lvl proj">project</span> The chief hires and fires to meet your needs, within the role rules. Each hire lands in the trail.</p>
<p><span class="lvl proj">override</span> You can change any agent's settings at any level, at any time. An override shows where it was set.</p>
<p class="note">Changing an agent's model is a trailed human action, applied to its running session on reload.</p>
</div>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,92 @@
// Renders the U0 samples to PNG with headless Chromium: every page at 1440x900 and 390x844,
// light and dark (prefers-color-scheme, no data-mode), 32 shots for 8 pages.
// Usage: node render.mjs <out-dir>
// Serves the repository over HTTP on 127.0.0.1, port 0, because Chromium refuses cross-file SVG
// <use> under file://. Only docs/design/ and the WebUI font directory are served.
// Checks each shot: no script error, no failed request, no sideways page scroll, fonts loaded.
import { createServer } from 'node:http';
import { readFile, mkdir } from 'node:fs/promises';
import { join, normalize, extname, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { browser } from '../../../../packages/webui/tests/browser.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const repo = normalize(join(here, '../../../..'));
const out = process.argv[2];
if (!out) { console.error('usage: node render.mjs <out-dir>'); process.exit(2); }
await mkdir(out, { recursive: true });
const ALLOW = ['docs/design/', 'packages/webui/src/public/assets/fonts/'];
const TYPES = { '.html': 'text/html', '.css': 'text/css', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.js': 'text/javascript' };
const failed = [];
const server = createServer(async (req, res) => {
const path = normalize(decodeURIComponent(new URL(req.url, 'http://x').pathname)).replace(/^\/+/, '');
if (path === 'favicon.ico') { res.writeHead(204).end(); return; }
if (path.includes('..') || !ALLOW.some((a) => path.startsWith(a))) { failed.push(`403 ${path}`); res.writeHead(403).end(); return; }
try {
const body = await readFile(join(repo, path));
res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'application/octet-stream' }).end(body);
} catch { failed.push(`404 ${path}`); res.writeHead(404).end(); }
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}/docs/design/samples/2026-11-09-unified-workspace/`;
const PAGES = ['01-sidebar', '02-dashboard', '03-org-dashboard', '04-projects', '05-inbox', '06-tasks', '07-side-by-side', '08-agents'];
const SIZES = [[1440, 900], [390, 844]];
const THEMES = ['light', 'dark'];
const CHECK = `(() => {
const vw = innerWidth, wide = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (!r.width || r.right <= vw + 1) continue;
let clipped = false;
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
const s = getComputedStyle(p);
if (/(auto|hidden|scroll|clip)/.test(s.overflowX) || s.position === 'fixed' && s.display === 'none') { clipped = true; break; }
}
if (!clipped && getComputedStyle(el).visibility !== 'hidden') wide.push(el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).join('.') : ''));
}
const faces = [...document.fonts].map((f) => f.family.replace(/"/g, '') + ' ' + f.weight + ' ' + f.status);
return { sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, wide: wide.slice(0, 8), faces,
manrope800: document.fonts.check('800 16px Manrope'), dark: matchMedia('(prefers-color-scheme: dark)').matches,
canvas: getComputedStyle(document.body).backgroundColor };
})()`;
const b = await browser({ machine: false });
const errors = [];
b.on('Runtime.exceptionThrown', (p) => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text));
b.on('Runtime.consoleAPICalled', (p) => { if (p.type === 'error') errors.push(p.args.map((a) => a.value ?? a.description).join(' ')); });
const report = [];
let bad = 0;
try {
for (const [w, h] of SIZES) {
await b.viewport(w, h);
for (const theme of THEMES) {
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: theme }] });
for (const name of PAGES) {
errors.length = 0; failed.length = 0;
await b.navigate(base + name + '.html');
await new Promise((r) => setTimeout(r, 250));
const info = await b.evaluate(CHECK);
const file = `${name}-${w}-${theme}.png`;
await b.screenshot(join(out, file));
const problems = [];
if (errors.length) problems.push('script: ' + errors.join(' | '));
if (failed.length) problems.push('requests: ' + failed.join(', '));
if (info.sw > info.cw) problems.push(`sideways scroll ${info.sw}>${info.cw}`);
if (info.wide.length) problems.push('past the edge: ' + info.wide.join(', '));
if (info.faces.some((f) => !f.endsWith('loaded') && !f.endsWith('unloaded'))) problems.push('fonts: ' + info.faces.join(', '));
if (!info.faces.some((f) => f.startsWith('Manrope') && f.endsWith('loaded') && !f.endsWith('unloaded'))) problems.push('Manrope not loaded');
if (info.dark !== (theme === 'dark')) problems.push('theme emulation failed');
if (problems.length) bad++;
report.push({ file, canvas: info.canvas, problems });
}
}
}
} finally {
await b.close();
server.close();
}
for (const r of report) console.log(`${r.problems.length ? 'FAIL' : 'ok '} ${r.file} ${r.canvas}${r.problems.length ? '\n ' + r.problems.join('\n ') : ''}`);
console.log(`${report.length} shots, ${bad} with problems`);
process.exit(bad ? 1 : 0);
@@ -0,0 +1,450 @@
/* U0 unified workspace samples, 2026-11-09. Static mocks, not product code.
Colours, type, spacing and radii come from tokens.css. The few additions are marked EXT
and listed in the U0 design report; nothing here redefines a token. */
@import url("../../tokens.css");
/* Fonts: the WebUI's self-hosted files, so the samples render with no network. The four
manrope-*.woff2 files are one variable font (weights 200 to 800), so one face covers 400-800. */
@font-face{font-family:"Manrope";font-style:normal;font-weight:200 800;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/manrope-400.woff2") format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:400;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2") format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:500;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2") format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:700;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2") format("woff2")}
/* EXT 1: org identity colours. Each organization's mark takes the action colour of the palette
its theme uses (11-07 ruling 7), copied from tokens.json for the three sample palettes, with
that palette's onAction for the initials. The shell keeps the user's own palette. */
:root{--org-grove:#377752;--org-rosewood:#a53b72;--org-atlantic:#19719a;--org-on:#ffffff}
@media (prefers-color-scheme:dark){:root:not([data-mode="light"]):not([data-mode="dim"]){--org-grove:#8fcca9;--org-rosewood:#d487af;--org-atlantic:#73c3e8;--org-on:#10141a}}
:root[data-mode="dark"]{--org-grove:#8fcca9;--org-rosewood:#d487af;--org-atlantic:#73c3e8;--org-on:#10141a}
:root[data-mode="dim"]{--org-grove:#8fcca9;--org-rosewood:#dc9ebe;--org-atlantic:#73c3e8;--org-on:#10141a}
[data-org="home"]{--org:var(--org-grove)}
[data-org="ddigital"]{--org:var(--org-rosewood)}
[data-org="forge"]{--org:var(--org-atlantic)}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
html,body{height:100%}
body{margin:0;background:var(--canvas);color:var(--text);font:400 14px/1.5 var(--font)}
h1,h2,h3,h4{color:var(--strong);margin:0;line-height:1.2;text-wrap:balance}
p{margin:0}
a{color:var(--action)}
code,kbd,.mono{font-family:var(--mono);font-size:12.5px}
kbd{border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 .35em;background:var(--surface);color:var(--strong);white-space:nowrap;font-size:11px}
button,select,input{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
.tnum{font-variant-numeric:tabular-nums}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
svg.i{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
svg.i.sm{width:15px;height:15px}
.label{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
.note{font-size:13px;color:var(--muted)}
.muted{color:var(--muted)}
.sim{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);border:1px dashed var(--accent);border-radius:4px;padding:0 5px;white-space:nowrap}
/* ---------- shell: command bar, section list, main, inspector (d3 layout) ---------- */
.app{display:grid;grid-template-columns:240px minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);height:100vh}
.app.with-insp{grid-template-columns:240px minmax(0,1fr) minmax(300px,360px)}
.cmdbar{grid-column:1/-1;display:flex;align-items:center;gap:12px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);min-width:0}
.cmdbar .mk{display:flex;align-items:center;gap:8px;font-weight:800;color:var(--strong);letter-spacing:-.01em;white-space:nowrap;text-decoration:none}
.cmdbar .mk svg{width:24px;height:auto}
/* The WebUI's temporary four-tile logo (index.html, processing.css), kept by owner ruling 11-09 until a real mark is chosen. */
.mosaic-logo{flex:none;fill:var(--action)}.mosaic-logo rect:nth-child(2){fill:color-mix(in srgb,var(--action) 75%,var(--strong))}.mosaic-logo rect:nth-child(4){fill:color-mix(in srgb,var(--action) 45%,var(--canvas))}
.cmdbar .menu{display:none;border:1px solid var(--line);background:var(--canvas);border-radius:var(--r);padding:4px;color:var(--strong)}
.cmdk{flex:1;max-width:520px;display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--canvas);border-radius:var(--r);padding:5px 10px;color:var(--muted);text-align:left;min-width:0}
.cmdk span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fresh{display:flex;align-items:center;gap:8px;margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap}
.fresh b{color:var(--success);font-weight:600}
.me{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--raised);color:var(--strong);font-weight:700;font-size:12px;flex:none}
.side{border-right:1px solid var(--line);background:var(--surface);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent;padding:10px 8px 14px;display:flex;flex-direction:column;gap:2px;min-height:0}
.nav{display:flex;align-items:center;gap:9px;padding:5px 8px;border-radius:var(--r);color:var(--text);text-decoration:none;font-weight:600;font-size:13.5px;min-width:0}
.nav:hover{background:var(--raised)}
.nav[aria-current="page"]{background:color-mix(in srgb,var(--action) 13%,var(--surface));color:var(--strong)}
.nav[aria-current="page"] svg.i{color:var(--action)}
.nav .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.n{margin-left:auto;font:600 11px var(--mono);color:var(--muted);font-variant-numeric:tabular-nums}
.n.hot{color:var(--surface);background:var(--danger);border-radius:999px;padding:0 6px}
.side hr,.specimen .mini hr{border:0;border-top:1px solid var(--line);margin:6px 4px}
.side .foot{margin-top:auto;padding-top:8px}
/* EXT 2: collapsible sidebar group. A label row with a chevron; closed groups keep their count. */
.grp{display:flex;flex-direction:column;gap:1px}
.grp-h{display:flex;align-items:center;gap:6px;padding:10px 8px 4px;color:var(--muted)}
.grp-h .label{flex:1}
.grp-h svg.i{width:14px;height:14px;transition:transform .15s}
.grp-h[aria-expanded="false"] svg.chev{transform:rotate(-90deg)}
.grp-h .seg{margin-left:auto}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--surface)}
.seg > span,.seg > button{border:0;background:none;padding:2px 8px;color:var(--muted);font-size:12px;font-weight:600;display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
.seg [aria-pressed="true"]{background:var(--action);color:var(--onAction)}
.seg.xs > span{padding:0 6px;font-size:11px}
/* EXT 3: org mark. Avatar tile in the org's colour with its initials (or image), always beside the
name or initials in text: colour is never the only cue. Sizes 16, 20, 28, 44. */
.av{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:5px;background:var(--org,var(--muted));color:var(--org-on);font:700 9.5px/1 var(--font);letter-spacing:.02em}
.av.xs{width:16px;height:16px;border-radius:4px;font-size:8.5px;letter-spacing:0}
.av.md{width:28px;height:28px;border-radius:7px;font-size:12px}
.av.lg{width:44px;height:44px;border-radius:10px;font-size:17px}
.av.all{background:var(--raised);color:var(--strong)}
.orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600}
.orgref.quiet{color:var(--text);font-weight:500}
.org-row .ic{color:var(--muted)}
.org-row .ic svg.i{width:15px;height:15px}
/* Org edge: a 3px stripe in the org colour on cards, rows and panes that belong to one org. */
.edge{box-shadow:inset 3px 0 0 var(--org)}
.pgroup{display:flex;flex-direction:column;gap:1px;margin-bottom:4px}
.pgroup-h{display:flex;align-items:center;gap:7px;padding:4px 8px 2px;font-size:12px;font-weight:700;color:var(--strong)}
.pgroup-h svg.i{width:13px;height:13px;color:var(--muted)}
.proj{padding-left:20px;padding-top:3px;padding-bottom:3px;font-weight:500;font-size:13px}
.proj .dot-s{font:400 12px/1 var(--glyph);width:12px;text-align:center}
.main{overflow:auto;min-width:0;min-height:0;padding:18px 24px 28px}
.crumb{font-size:12.5px;color:var(--muted);margin-bottom:4px;display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.crumb a{color:var(--muted)}
.vh{display:flex;flex-wrap:wrap;gap:8px 14px;align-items:center;margin-bottom:14px}
.vh h1{font-size:28px;font-weight:800;letter-spacing:-.02em}
.vh .sub{flex-basis:100%;font-size:13.5px;color:var(--muted)}
.vh .right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
h2.sec{font-size:20px;font-weight:800;letter-spacing:-.015em}
h3.q{font-size:16px;font-weight:700}
.sec-h{display:flex;align-items:baseline;gap:10px;margin:22px 0 8px;flex-wrap:wrap}
.sec-h h2,.sec-h h3{font-size:15px;font-weight:700}
.sec-h .note{flex:1}
.sec-h a{font-size:13px;font-weight:600;text-decoration:none}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);padding:16px;min-width:0}
.panel.tight{padding:12px 14px}
.grid{display:grid;gap:var(--gap)}
.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
.g5{grid-template-columns:repeat(5,minmax(0,1fr))}
.split{display:grid;gap:16px;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);align-items:start}
/* status vocabulary: symbol plus word, never colour alone */
.dot{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
/* EXT 10, status glyph font: Manrope has no geometric shapes, and the generic fallback (Liberation Sans here)
draws the waiting half-circle at half the size of the dot. Name fonts that draw all five at one size. */
:root{--glyph:"DejaVu Sans","Segoe UI Symbol","Apple Symbols","Noto Sans Symbols 2",var(--font)}
.dot b{font:400 13px/1 var(--glyph);width:14px;text-align:center}
.s-working b{color:var(--success)}.s-waiting b{color:var(--warning)}.s-error b{color:var(--danger)}
.s-offline b,.s-idle b,.s-unknown b{color:var(--muted)}
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap}
.chip.gated{background:color-mix(in srgb,var(--danger) 14%,var(--surface));color:var(--danger)}
.chip.block{background:var(--danger);color:var(--surface)}
.chip.cross{background:color-mix(in srgb,var(--accent) 16%,var(--surface));color:var(--accent)}
.chip.within,.chip.routine{background:var(--raised);color:var(--muted)}
.chip.warn{background:color-mix(in srgb,var(--warning) 16%,var(--surface));color:var(--warning)}
.chip.ok{background:color-mix(in srgb,var(--success) 15%,var(--surface));color:var(--success)}
.chip.outline{background:none;border:1px solid var(--line);color:var(--text);font-weight:500}
.chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.chips .f{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font-size:12.5px;font-weight:600;color:var(--text);white-space:nowrap}
.chips .f.plain{padding-left:10px}
.chips .f[aria-pressed="true"]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
.chips .f .av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
.chips .f .n{margin-left:2px;color:inherit;opacity:.8}
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font-size:13px;font-weight:600;color:var(--strong);white-space:nowrap;text-decoration:none}
.btn.pri{background:var(--action);border-color:var(--action);color:var(--onAction)}
.btn.ghost{border-color:transparent;background:none;color:var(--muted)}
.btn svg.i{width:15px;height:15px}
.sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:3px 8px;font-size:13px;color:var(--strong);white-space:nowrap}
.sel .label{font-size:10px}
.sel svg.i{width:13px;height:13px;color:var(--muted)}
.search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:3px 8px;color:var(--muted);font-size:13px;min-width:180px}
.toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
.toolbar .right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
/* dense tables */
.tbl{overflow-x:auto;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
table{border-collapse:collapse;width:100%;font-size:13px}
th{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;background:var(--surface)}
th.sorted{color:var(--strong)}
th .ar{font-family:var(--mono);margin-left:3px;color:var(--action)}
td{padding:7px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);vertical-align:middle}
tr:last-child td{border-bottom:0}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
td .t1{color:var(--strong);font-weight:600}
td .t2{display:block;font-size:12px;color:var(--muted)}
tbody tr.row[aria-selected="true"]{background:color-mix(in srgb,var(--action) 10%,var(--surface));box-shadow:inset 3px 0 0 var(--action)}
tbody tr.dim td{color:var(--muted)}
/* EXT 4: metric tile with a per-org breakdown. The total is the headline; the stacked bar and the
labelled list under it carry the same numbers by org, so the bar is never read on colour alone. */
.tile{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:12px 14px;min-width:0;display:grid;gap:6px;align-content:start}
.tile .big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
.tile .big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px}
.tile .delta{font-size:12px;color:var(--muted)}
.tile .delta b{color:var(--success);font-weight:600}
.tile .delta b.down{color:var(--danger)}
.bar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--raised);gap:2px}
.bar i{display:block;background:var(--org,var(--action))}
.by{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--text)}
.by span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums}
.by .av{width:14px;height:14px;border-radius:3px;font-size:7px}
/* activity trail across orgs */
.trail{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
.trail li{display:grid;grid-template-columns:48px 20px minmax(0,1fr);gap:10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px;align-items:center}
.trail li:last-child{border-bottom:0}
.trail time{font-family:var(--mono);font-size:12px;color:var(--muted)}
.trail .what{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.trail .what b{color:var(--strong);font-weight:600}
.trail .what .src{color:var(--muted)}
/* needs-you list (inbox rows on dashboards) */
.needs{list-style:none;margin:0;padding:0;display:grid;gap:8px}
.needs li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:4px 10px;align-items:center;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:9px 12px}
.needs .q{color:var(--strong);font-weight:600;font-size:13.5px}
.needs .src{grid-column:2;font-size:12px;color:var(--muted)}
.needs .chip{grid-row:1/3;grid-column:3}
.kv{display:grid;grid-template-columns:auto 1fr;gap:5px 12px;margin:0;font-size:13px}
.kv dt{color:var(--muted)}
.kv dd{margin:0;color:var(--strong);min-width:0;overflow-wrap:anywhere}
/* EXT 5: settings index. Every configurable is one row: group, current value, where the value comes
from, and a link to its editor. The dashboard reaches every setting without showing all inline. */
.cfg{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);overflow:hidden}
.cfg li{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:2px 10px;align-items:center;padding:9px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
.cfg li:nth-child(2n){border-right:0}
.cfg li:nth-last-child(-n+2){border-bottom:0}
.cfg svg.i{color:var(--muted);width:16px;height:16px}
.cfg .k{font-weight:600;color:var(--strong);font-size:13px}
.cfg .v{grid-column:2;font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cfg .go{grid-row:1/3;grid-column:3;font-size:12.5px;font-weight:600;text-decoration:none}
.src-tag{font:500 11px var(--mono);color:var(--accent)}
.docs{list-style:none;margin:0;padding:0;display:grid;gap:2px}
.docs li{display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:10px;align-items:center;padding:6px 4px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px}
.docs li:last-child{border-bottom:0}
.docs svg.i{width:16px;height:16px;color:var(--muted)}
.docs .d{color:var(--strong);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.docs time{font-size:12px;color:var(--muted);white-space:nowrap}
/* org header on the org dashboard */
.orghead{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px}
.orghead h1{font-size:28px;font-weight:800;letter-spacing:-.02em;display:flex;align-items:center;gap:10px}
.orghead h1 svg.i{width:20px;height:20px;color:var(--muted)}
.orghead .meta{flex-basis:100%;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:13px;color:var(--muted);margin-top:-6px;padding-left:58px}
.orghead .right{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
.typechips{display:flex;flex-wrap:wrap;gap:6px}
/* EXT 6: Kanban. Columns by state; a card carries the org mark, the task id from its tracker, the
project and the assignee's status. Cards from several orgs share one column. */
.board{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;align-items:start}
.col{background:color-mix(in srgb,var(--raised) 55%,var(--canvas));border:1px solid var(--line);border-radius:var(--r-lg);padding:8px;display:grid;gap:8px;min-width:0}
.col-h{display:flex;align-items:center;gap:8px;padding:2px 4px}
.col-h h3{font-size:13px;font-weight:700}
.col-h .n{margin-left:0}
.col-h .mix{margin-left:auto;display:flex;gap:2px}
.kcard{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px 8px 12px;display:grid;gap:5px;min-width:0}
.kcard .top{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.kcard .top .id{font:500 11.5px var(--mono);color:var(--muted)}
.kcard .top .trk{margin-left:auto}
.kcard .tt{color:var(--strong);font-weight:600;font-size:13px;line-height:1.35}
.kcard .bot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);flex-wrap:wrap}
.kcard.you{border-color:color-mix(in srgb,var(--warning) 55%,var(--line))}
/* EXT 7: tracker badge. Names the task tracker module behind an org: built-in or Vikunja. */
.trk{display:inline-flex;align-items:center;gap:4px;font:600 10.5px var(--mono);letter-spacing:.02em;padding:0 5px;border-radius:3px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
.trk.vik{border-style:dashed;color:var(--accent);border-color:color-mix(in srgb,var(--accent) 60%,var(--line))}
.trackers{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;font-size:12.5px;color:var(--text);padding:8px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);margin-bottom:12px}
.trackers > span{display:inline-flex;align-items:center;gap:6px}
/* EXT 8: side-by-side panes. Each pane has its own header with the org mark and edge, and its own
scroll; panes never share state. Two panes at 1440; one pane with a pane switcher on a phone. */
.panes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0;height:100%;min-height:0}
.pane{display:flex;flex-direction:column;min-width:0;min-height:0;border-right:1px solid var(--line)}
.pane:last-child{border-right:0}
.pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org);min-width:0}
.pane-h .ttl{font-weight:700;color:var(--strong);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px}
.pane-h .tools{margin-left:auto;display:flex;gap:2px}
.pane-h .tools span{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted)}
.pane-body{overflow:auto;padding:14px;min-height:0;flex:1}
.pane-tabs{display:none}
.main.flush{padding:0;overflow:hidden;display:flex;flex-direction:column}
.panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap}
.panebar h1{font-size:16px;font-weight:700}
/* EXT 9: agent level badge and the three-level map. */
.lvl{display:inline-flex;align-items:center;gap:4px;font:700 10.5px var(--font);letter-spacing:.06em;text-transform:uppercase;padding:1px 6px;border-radius:3px;white-space:nowrap}
.lvl.root{background:var(--strong);color:var(--surface)}
.lvl.org{background:color-mix(in srgb,var(--org,var(--action)) 18%,var(--surface));color:var(--strong);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--org,var(--action)) 50%,transparent)}
.lvl.proj{background:none;color:var(--text);box-shadow:inset 0 0 0 1px var(--line)}
.levels{display:grid;grid-template-columns:200px repeat(3,minmax(0,1fr));border:1px solid var(--line);border-radius:var(--r-lg);overflow:hidden;background:var(--surface)}
.levels > div{padding:10px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent);min-width:0}
.levels > div:nth-child(4n){border-right:0}
.levels .lh{background:color-mix(in srgb,var(--raised) 45%,var(--surface));display:grid;gap:4px;align-content:start}
.levels .lh small{font-size:11.5px;color:var(--muted);line-height:1.35}
.levels .lh .lvl{justify-self:start}
.levels .oh{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--strong);background:color-mix(in srgb,var(--raised) 45%,var(--surface))}
.levels .span3{grid-column:2/5;border-right:0}
.levels .last{border-bottom:0}
.agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:2px 10px 2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);background:var(--canvas);white-space:nowrap;margin:2px 4px 2px 0}
.agent .dot b{width:auto}
.agent.assist{border-color:var(--action);background:color-mix(in srgb,var(--action) 10%,var(--surface))}
.agent.cos{border-style:solid}
.agent small{font-weight:500;color:var(--muted)}
.inst{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.span-band{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 12px;border-radius:var(--r-table);border:1px solid var(--action);background:color-mix(in srgb,var(--action) 8%,var(--surface));font-size:13px;color:var(--strong)}
.span-band .orgs{display:flex;gap:4px}
/* inspector */
.insp{border-left:1px solid var(--line);background:var(--surface);overflow:auto;min-height:0;display:flex;flex-direction:column}
.insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--surface);z-index:1}
.insp header .x{margin-left:auto;color:var(--muted);display:inline-flex}
.insp .body{padding:14px;display:grid;gap:14px}
.insp h3{font-size:16px;font-weight:700}
.opts{list-style:none;margin:0;padding:0;display:grid;gap:8px}
.opts li{border:1px solid var(--line);border-radius:var(--r-table);padding:9px 10px;display:grid;gap:6px;background:var(--canvas)}
.opts li.rec{border-color:var(--action)}
.opts .k{font:700 13px var(--mono);color:var(--strong)}
.opts .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.cmd{display:flex;gap:6px;align-items:center;min-width:0}
.cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--strong)}
ol.hist{list-style:none;margin:0;padding:0;display:grid;gap:6px;font-size:12.5px}
ol.hist li{display:grid;grid-template-columns:48px 1fr;gap:8px}
ol.hist time{font-family:var(--mono);color:var(--muted)}
.auth{border:1px solid var(--line);border-left:3px solid var(--org,var(--action));border-radius:0 6px 6px 0;padding:8px 10px;background:var(--canvas);font-size:13px;color:var(--strong)}
/* inbox list */
.ibx td.src{white-space:nowrap}
.ibx .q{color:var(--strong);font-weight:600}
.ibx .via{display:block;font-size:12px;color:var(--muted)}
/* sidebar details: add button, collapsed marks, project rail */
.grp-h .add{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:var(--r);color:var(--muted);border:1px solid var(--line)}
.grp-h .n{margin-left:0}
.collapsed-marks{display:flex;gap:4px;padding:0 8px 6px 28px}
.pgroup .projs{margin-left:17px;border-left:2px solid var(--org);display:flex;flex-direction:column;gap:1px}
.pgroup .proj{padding-left:10px}
.proj.flat{padding-left:8px}
.proj .n{font-family:var(--font);font-weight:500;font-size:11px}
.s-ok b{color:var(--success)}
/* tasks by state on the main dashboard */
.states{display:grid;gap:8px}
.srow{display:grid;grid-template-columns:112px 24px minmax(0,1fr) auto;gap:10px;align-items:center;font-size:13px}
.srow .sl{color:var(--strong);font-weight:600}
.srow .sn{font:700 13px var(--mono);color:var(--strong);text-align:right}
.srow .bar{height:8px;max-width:100%}
.needs li > .av{grid-row:1/3}
/* settings index, one column, with the destructive row set apart */
.cfg.one{grid-template-columns:minmax(0,1fr)}
.cfg.one li{grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;border-right:0;padding:7px 12px}
.cfg.one .v{grid-column:3;grid-row:1}
.cfg.one .src-tag{grid-column:4;grid-row:1}
.cfg.one .go{grid-column:5;grid-row:1}
.cfg.one li:nth-last-child(2){border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
.cfg li.danger .k,.cfg li.danger svg.i,.cfg li.danger .go{color:var(--danger)}
/* live transcript, mini tiles and sub tabs inside a pane */
.term{margin:0;font:400 12px/1.55 var(--mono);background:var(--canvas);color:var(--text);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px;white-space:pre-wrap;overflow-wrap:anywhere}
.term .m{color:var(--muted)}
.term .c{color:var(--action)}
.mini-tiles .tile{padding:8px 10px;gap:2px}
.mini-tiles .tile .big{font-size:20px}
.subtabs{display:flex;gap:2px;border-bottom:1px solid var(--line);margin:-4px 0 12px}
.subtabs span{padding:4px 10px;font-size:13px;font-weight:600;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px}
.subtabs [aria-current="page"]{color:var(--strong);border-bottom-color:var(--org,var(--action))}
.rules{display:grid;gap:10px;font-size:13px}
.rules p{display:flex;gap:6px;flex-wrap:wrap;align-items:baseline}
/* one-line session rows on the main dashboard */
.sess td.who{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0;width:100%}
.sess td.who .orgref{vertical-align:middle}
.sess td.st{white-space:nowrap}
/* docs row on the org dashboard */
.docrow{margin-top:8px}
.docrow .doc{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);text-decoration:none;white-space:nowrap}
.docrow .doc svg.i{width:14px;height:14px;color:var(--muted)}
.docrow .doc small{font-weight:500;color:var(--muted)}
.docrow .more{font-size:13px;font-weight:600;text-decoration:none;margin-left:4px}
/* source line inside a row, used on a phone where the source column is hidden */
.msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
.av.mob{display:none}
/* phone-only labels and rows; desktop hides them */
.show-m,.mlabel{display:none}
td .t2.show-m{display:none}
/* mobile strip and drawer */
.strip{display:none}
.specimen{display:grid;gap:14px;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start}
.specimen .mini{border:1px solid var(--line);border-radius:var(--r-lg);background:var(--surface);padding:8px;display:flex;flex-direction:column;gap:2px}
.specimen figcaption{font-size:12.5px;color:var(--muted);margin-top:6px}
.specimen figure{margin:0}
.callouts{margin:14px 0 0;padding:0 0 0 18px;display:grid;gap:6px;font-size:13px}
.callouts b{color:var(--strong)}
@media (max-width:1100px){
.app.with-insp{grid-template-columns:220px minmax(0,1fr)}
.app.with-insp .insp{position:fixed;right:0;top:49px;bottom:0;width:min(380px,100%);box-shadow:-12px 0 30px -12px color-mix(in srgb,#000 35%,transparent);z-index:10}
.g5{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:760px){
.app,.app.with-insp{display:block;height:auto;min-height:100vh}
.cmdbar{position:sticky;top:0;z-index:20;gap:8px;padding:8px 12px}
.cmdbar .menu{display:inline-flex}
.cmdbar .mk span,.cmdbar .sim,.fresh .wide{display:none}
.cmdk{max-width:none}
.cmdk kbd{display:none}
.fresh{margin-left:0}
.side{display:none}
.strip{display:flex;gap:2px;overflow-x:auto;padding:6px 8px;border-bottom:1px solid var(--line);background:var(--surface)}
.strip .nav{white-space:nowrap;flex:none}
.app.drawer .side{display:flex;position:fixed;inset:49px 0 0 0;z-index:30;border-right:0;padding:10px 12px 20px}
.app.drawer .strip{display:none}
.main{padding:14px 16px 24px;overflow:visible}
.vh h1,.orghead h1{font-size:24px}
.g2,.g3,.g4,.g5,.split,.specimen{grid-template-columns:minmax(0,1fr)}
.g4.keep2,.g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))}
.app.with-insp .insp{position:static;display:flex;width:auto;box-shadow:none;border-left:0;border-top:1px solid var(--line);margin:0 0 24px}
.show-m,td .t2.show-m{display:block}
span.show-m{display:inline-flex}
span.msrc.show-m{display:flex}
.av.mob{display:inline-grid;margin-right:6px;vertical-align:-3px}
.strip{scrollbar-width:none}
.g5.keep2 > :last-child:nth-child(odd){grid-column:1/-1}
.sec-h .note{flex-basis:100%;order:3}
.grid.g3.mini-tiles{grid-template-columns:repeat(3,minmax(0,1fr))}
.mini-tiles .tile .big{font-size:18px}
.mini-tiles .tile .big small{display:block;margin-left:0;font-size:11.5px}
.cfg.one li{grid-template-columns:18px minmax(0,1fr) auto auto;gap:2px 10px}
.cfg.one .k{grid-column:2;grid-row:1}
.cfg.one .src-tag{grid-column:3;grid-row:1}
.cfg.one .go{grid-column:4;grid-row:1/3}
.cfg.one .v{grid-column:2/4;grid-row:2}
.mlabel{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--strong);margin-bottom:6px}
.board,.tasklist .sec-h{display:none}
.vh .seg .kb{background:none;color:var(--muted)}
.vh .seg .ls{background:var(--action);color:var(--onAction)}
.srow{grid-template-columns:92px 20px minmax(0,1fr)}
.srow .by{grid-column:1/-1}
.cfg{grid-template-columns:minmax(0,1fr)}
.cfg li{border-right:0}
.cfg li:nth-last-child(2){border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
.orghead .meta{padding-left:0;margin-top:0}
.orghead .right{margin-left:0}
.levels{grid-template-columns:minmax(0,1fr)}
.levels > div{border-right:0}
.levels .span3{grid-column:auto}
.levels .hide-m{display:none}
.panes{grid-template-columns:minmax(0,1fr);height:auto}
.pane{border-right:0}
.pane.alt{display:none}
.pane-tabs{display:flex}
.main.flush{overflow:visible}
.hide-m{display:none}
.trail li{grid-template-columns:42px 18px minmax(0,1fr);gap:8px}
.trail .what{white-space:normal}
.toolbar .right{margin-left:0}
.search{min-width:0;flex:1}
}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+1 -2
View File
@@ -1,7 +1,6 @@
# PRD version 1.20, draft 2: unified workspace
- Status: draft, not approved. Jason approves the exact text before it
enters `docs/prd/mosaic-stack.md` as version 1.20.
- Status: approved as version 1.20 on 2026-11-09; landed in docs/prd/mosaic-stack.md
- Source: `docs/plans/2026-11-09_unified-workspace-owner-direction.md`,
Jason's direction and answers of 2026-11-09.
- Drafted by Mos (lead).
@@ -231,9 +231,46 @@ into another user's organization, and access checks on the cross-org read
model. The design lands with Phase U so the read model has membership from
the start.
## U0 approval (11-09, 19:26Z)
Owner text, verbatim:
> The U0 screen designs look good. We need to retain the temporary logo
> already used in the site.
> I don't like these module names either. It isn't clicking for me. That may
> need to be a later decision.
What follows from it:
1. The samples in `docs/design/samples/2026-11-09-unified-workspace/` are the
acceptance target for U2 to U5.
2. The WebUI's four-tile mark stays the logo. The samples now draw it in
place of the design system's Relay placeholder, and the build does not
adopt Relay.
3. The Montage module name stays open. Two naming rounds produced no pick.
The label "Mosaic Montage" stays until Jason names it; nothing in phase U
depends on the name.
The ten open questions in the U0 report were not answered one by one. Under
the lead's standing authority to decide routine choices, phase U takes the
report's recommendations, and Jason can overturn any of them:
- Every decide command copied from a shared view carries `--org`.
- The chief may propose a root-level hire only as a gated inbox item the user
approves.
- An org's colour is its own theme's action colour, the same for every member.
- Side by side on a phone is a two-way switch with one pane visible.
- Sidebar projects default to By org, remembered per viewer.
- Tasks on a phone default to List, with Kanban one tap away.
- The CLI verbs for the new header controls are `mosaic org reload <id>` and
`mosaic console restart`, added before U2 wires the buttons.
- Members and invites shows in the settings index, labelled "not built".
- Messages becomes Assistant. Docs live on org dashboards and in panes. Queue
opens from each org dashboard.
## Open items
- The final name for the Mosaic Montage module.
- The final name for the Mosaic Montage module (deferred by Jason, 11-09).
- Which "sign-in (model proxy)" step Jason hit. F3 inventories every manual
step on the dev instance and reports it.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,361 @@
# Agent levels and the cross-organization assistant (Phase A design, #1873)
Status: design for lane org-a1, 2026-11-10. Slice A1 (read-only store, union
resolution, Agents grid) is built on branch `org-a1`. Everything that gives an
agent a new capability is in the roles proposal and the later slices listed in
section 9; none of it is applied.
Direction: PRD 1.20 draft 2 (REQ-ASSIST-1, REQ-USER-1 amendment, REQ-AGENT-1
to REQ-AGENT-3, REQ-COS-3, REQ-DEC-2, REQ-ROLE-3), owner direction rulings 4
and 11, the U0 approval and the addendum in
`docs/plans/2026-11-09_unified-workspace-owner-direction.md`, and the role model
in `roles/` and `docs/plans/2026-09-02_atomic-mosaic-foundation.md`.
Owner words: "Agents are configured at three levels: the user's root, the
organization and the project. A project's agents are the union of all three.
The user's main interaction agent (the assistant) is the one exception to
isolation. [...] Every other agent is isolated to the organization it works
in." And: "the COS should be able to hire and fire agents to fulfill the
user's needs."
## 1. Terms
- **Definition**: a named agent entry at one level. It names a role
definition in `roles/` (`definition`) and may carry vars. It never names a
credential, a holder, a tracker bot, an identity or a publish grant at the
root or project level.
- **Role definition**: `roles/<name>.json`, versioned and changed only by
reviewed commits (invariant 7). A level chooses among role definitions and
never creates one, so no level can grant an authority that `roles/` does
not already hold.
- **Copy**: a root definition as it runs inside one organization. Each
organization runs its own copy, and each copy has that organization's
memory, files and credentials.
- **Union**: the agents available in one project of one organization.
## 2. Where each level lives
| Level | File | Writer today | Owner |
|---|---|---|---|
| root | `<configDir>/agents.json`, next to the instance's system config | the user, by hand (no writer in A1) | the instance's OS user |
| org | `roles` in `<configDir>/businesses/<org>.json` (unchanged) | existing org writers | the organization |
| project | `projects.<project>.agents` in the same organization file | the owner's `updateOrg` only; no agent writer can reach it | the organization |
### Root: `<configDir>/agents.json`
`configDir` is the directory of the instance's system config
(`~/.config/mosaic-<instance>/`). The file sits outside the data root, so it is
outside every organization root (`<dataRoot>/org-runtime/<org>`) and every
bus, and no bus or sandbox mount reaches it. It is per instance, as the system
config is (invariant 2). When accounts arrive (Phase M), it moves to a
per-principal path under the same directory; the format stays.
```json
{
"agentsVersion": 1,
"agents": {
"assistant": { "definition": "assistant", "vars": { "model": "opus-5.5" } },
"reviewer": { "definition": "reviewer", "excludeOrgs": ["home"] },
"researcher": { "definition": "researcher" }
}
}
```
- Keys: `definition` (required), `vars` (agent layer, `checkVars(..., "agent")`),
`excludeOrgs` (distinct organization ids; "reviewer turned off for Home" in
sample 08). Any other key refuses.
- The file must be a regular file opened without following a link, owned by
the process uid and not writable by group or other, the same checks as the
organization file. A missing file means no root agents. Anything else that
is wrong refuses the whole root level (section 6).
- Refused at the root: a role definition that is version 1 or missing, the
`chief-of-staff` definition (each organization's chief is its own,
REQ-COS-3), an `assistant` definition under any name but `assistant`, and
`credentials`, `holder`, `tracker`, `identity`, `publish`, `hosts`.
### Organization: `roles`
Unchanged. Every organization instance stays as it is, validated by
`validateBusinessDocument`, with its credential map, tracker bot and identity.
### Project: `projects.<project>.agents`
The project level is in the organization file, not in the project repository.
The repository's `.mosaic/project.json` can be written by any agent that can
push to the project, so a definition there would let an agent create agents.
The organization file is reachable only by the trusted writer, and the
existing projects fence (`organization-projects-protected` in
`organization.mjs`) already refuses every general write that changes
`projects`. No agent writer can add, change or remove a project agent. The
owner's `updateOrg` can, and the Console's organization editor holds the key
on the server (`SERVER_PROJECT_KEYS`), so the browser draft cannot change it in
A1.
```json
"projects": {
"spring-launch": {
"root": "...",
"agents": {
"social-scheduler": { "definition": "pm", "vars": { "model": "haiku-5.5" } },
"coder": { "vars": { "limits.network": "none" } },
"reviewer": { "exclude": true }
}
}
}
```
Each entry has exactly one form:
- **define**: `{ "definition", "vars"? }`. Agent-layer vars. Not
`chief-of-staff`, not `assistant`.
- **narrow**: `{ "vars" }` only. Every key must be an intersect key
(`limits.tools`, `limits.network`, `limits.authority`), checked at the
project layer. A project narrows an inherited agent and never widens it.
- **exclude**: `{ "exclude": true }`. The inherited agent is not available in
this project.
`validateBusinessDocument` checks each entry's shape and role definition for
every reader (bus, Console, CLI). It does not copy `agents` into the loaded
business object, so a bus built from the file sees exactly what it saw before.
A project that is deleted takes its agents with it, and so does a deleted
organization.
Library projects (`kind: "library"`) keep their own mapping check and take no
`agents` key.
This is an additive, optional key in the organization file. It is not a bus
store (SQLite) schema change: no migration, no new table, no bus verb.
## 3. Union resolution
For organization O and project P:
1. Start with the root agents, minus any whose `excludeOrgs` names O.
2. Lay the organization's `roles` over them. An org instance with the same
name as a root agent replaces it entirely; the root entry is recorded as an
override of that agent.
3. Lay P's entries over the result:
- **define** replaces any inherited agent of that name; the inherited
entry is recorded as an override.
- **narrow** merges vars through `mergeVars`, inherited layer first and the
project layer after it, so limits only intersect. The keys it narrowed
are recorded.
- **exclude** removes the agent and records who removed it.
Rules:
- The most specific level that defines a name wins, and the whole entry
wins: definition and vars, never a field-by-field mix.
- A narrow or exclude entry naming an agent that no lower level provides
refuses. A typo never silently does nothing.
- A project entry of any form naming the organization's chief (an instance
whose definition is `chief-of-staff`) or the assistant refuses. A project
cannot remove, narrow or replace the chief or the assistant.
- Two levels never merge vars, apart from a project narrowing. Root vars stay
with the root definition, and an org instance of the same name brings its
own.
- The result for each agent: name, definition, level, source (which file),
the overrides it shadows, the keys narrowed, and its scope. Every agent in a
union is scoped to O, an organization's own `assistant` instance included.
Only the root assistant, which no union holds, is cross-organization.
- The union also lists what O or P does not get: root agents turned off for
O and agents P excluded. A name that a more specific level then defines is
available under that definition and is not listed.
Implementation: `packages/organization/src/agent-levels.mjs`
(`loadRootAgents`, `checkRootAgents`, `checkProjectAgents`,
`resolveAgentLevels`, `projectUnion`). It is pure apart from the one root file
read.
## 4. Isolation of every non-assistant agent
Every agent except the root assistant resolves to scope `{ org: O }` in the
organization it runs in, whichever level defined it. The scope fixes three
things:
- **Memory and records**: under the organization's root,
`<dataRoot>/org-runtime/O/...`, on that organization's bus, as every org
instance has today. A root agent's copy in O and its copy in O2 share
nothing: no thread, no record, no session file.
- **Files**: the project workspaces of O only. The sandbox mounts come from
O's project mapping, never from another organization's.
- **Credentials**: only from O's credential map. A root definition carries
none (refused, section 2). In A1 a root or project agent is shown as
unbound: it has no credential and cannot be launched, because the bus
builds instances from `roles` alone and the loaded business object does not
carry project agents.
When root copies run (slice A3), each copy becomes a bus instance in O under
O's writer, with O's credential bindings, through the same `checkRoles` path an
org instance takes. A copy never reads the root file at run time; it gets its
definition when O's bus boots.
## 5. The assistant across organizations
### Reads
The assistant reads another organization only through the Console's
membership-checked cross-organization read: `organizations.members(session)`
(`packages/webui/src/organizations.mjs`), the read `GET /api/workspace` uses.
Each organization is admitted by `showOrg` (owner check, registry digest,
document validation) and `resolveOrg` (marker, modes) before any transport is
built, and each bus read goes over that organization's own socket with a
deadline. The assistant process gets no org root path, no bus socket path and
no file mount of any organization. It calls a read broker that the Console
holds for the owner's session. A direct read of another organization's bus
files does not exist as a code path: the assistant sandbox mounts only its own
memory directory.
### Shared memory
The assistant's one memory lives at `<dataRoot>/assistant/` (mode 0700, owned
by the instance user). It sits inside the instance data root and outside every
`org-runtime/<org>` root, so deleting or archiving an organization never
touches it, and no organization's agents can mount it. It holds the
assistant's notes and session files. It never holds a copy of an
organization's records; what the assistant learns from an organization stays
in that organization, plus whatever the assistant chose to write in its own
memory.
### Authority
REQ-ASSIST-1: the assistant talks with the user and hands each organization's
work to that organization's chief. It does not direct agents, create tasks or
launch sessions. Its role keeps `message.send` and `record.write` only. The
roles proposal (section 8) adds no action to it.
### A1 reality
Today each organization file declares its own `assistant` instance. A1 shows
those as org agents. A root `assistant` entry, if the user writes one, shows in
the band as the root assistant with cross-organization scope. Nothing reads
across organizations for it yet.
## 6. Fail-closed boundaries
| Boundary | A1 behavior | Later slices |
|---|---|---|
| Root file (new) | `O_NOFOLLOW` regular file, process uid, no group or other write; any problem refuses the whole root level and the grid says so; a missing file is "no root agents". The refusal names the file and the key at fault, never a value from the file: parser text and a var check's quote of the refused value are dropped, since a value can be a secret typed in the wrong place. Project var refusals drop the value the same way. | The root writer accepts only the human session (section 7). |
| Organization file `projects.<p>.agents` (new key) | Checked by `validateBusinessDocument` for every reader; a bad entry refuses the whole organization file, as any bad key does. Not copied into the loaded business, so buses gain nothing. The projects fence keeps every agent writer out. | A project-agent writer verb, with its own guard, like `addProject`. |
| Union | A narrow or exclude naming nothing refuses that project; touching the chief or assistant refuses that project; the grid shows the refusal for that project. | A launch resolver refuses to launch any agent in a project whose union refuses. |
| Console read `GET /api/agents/levels` (new) | Owner only (granted users and the tester get `owner-only`, 403); organizations come only from `organizations.members(session)`; the body carries ids, names, role definition names, model var and level. It never carries a path, a credential reference or a var other than `model` and the narrowed key names. | Unchanged. |
| Org isolation | Root and project agents are not instances on any bus; nothing reads across organizations except the existing Console read. | Each copy is materialized by O's own writer and runs under O's credentials. |
| Assistant memory | Not built. | `<dataRoot>/assistant/` with 0700; the sandbox mounts it and nothing else. |
| Roles | No change in commits. | The proposal patch, applied only after Jason approves it. |
A root file that refuses hides the root level everywhere rather than showing a
partial list: an exclusion that did not parse must not let an excluded agent
appear.
## 7. Chief-of-staff hire and fire, per level
Today `roles/chief-of-staff.json` lists `role.hire` and `role.fire` within its
role, with `"hireable": []`: it can hire nothing. Each level adds a write path;
none exists in A1.
- **Organization level** (REQ-COS-3): the chief hires or fires an
organization instance through the organization's trusted writer, for a role
in its `hireable` list. Each hire lands in the action trail. The user can
override any setting at any time through REQ-ROLE-3.
- **Project level**: the chief adds or removes a project entry through a new
writer verb that changes only `projects.<p>.agents` (a guard like
`addProject`'s), for a role in `hireable`. The projects fence still refuses
every other change.
- **Root level** (U0 ruling): the chief cannot write the root file. It raises
an inbox decision (REQ-DEC-2) in its own organization that names the role
definition, the agent name and the reason. The user approves or denies it in
the Console inbox. On approval the Console, acting with the human's session,
writes the root file through a root writer that refuses any actor other than
the signed-in owner (`human-required`). A denied or expired decision changes
nothing. A fire works the same way. The chief never learns the file's path.
`hireable` decides what can be hired at all; widening it is a `roles/` change
and needs Jason's reviewed commit. The proposal (section 8) names the roles.
## 8. Authority changes proposed, not applied
The patch is at `/mnt/storage/scratch/mosaic-lead/reports/org-a1-roles-proposal.patch`.
It is never applied on this branch. It applies with `git apply` on
`origin/refactor` and needs a reviewed commit that Jason approves.
What it changes:
- `roles/chief-of-staff.json`: `hireable` goes from `[]` to `coder` and the
five advisory chiefs (`chief-executive-officer`, `chief-financial-officer`,
`chief-information-officer`, `chief-information-security-officer`,
`chief-marketing-officer`). These are the only shipped definitions that pass
today's hire check: version 2, not creative, and every action on
`HIRE_ALLOWED`. The chief can then hire at the organization level with the
existing `hire` verb. The project level (A4) uses the same list.
- `roles/chief-of-staff.md`: drops "your empty hireable list", names the list,
says a hired coder has no credential until the human binds one, and says the
chief hires only in its own organization, never at the root.
- `packages/organization/tests/chief-of-staff.test.mjs` and `role.test.mjs`:
the tests that pin the shipped chief file move to the new list, and the
contract test pins the new sentence. Two bus test comments that said "shipped chief hireable:[]" are
reworded. These go in the same commit, or the organization suite fails.
What it leaves alone, and why:
- `roles/assistant.json`: no change. The cross-organization read is the
Console's read broker acting for the owner's session (section 5), not a
role action, so the assistant needs no new grant. It keeps `message.send`
and `record.write`. Adding an action would widen it for no gain.
- `roles/assistant.md`: no change yet. Its "one user in one business" text
stays true until A6 builds the root assistant; the contract text for the
cross-organization read and the shared memory lands with A6, in that
slice's own reviewed commit.
- Root-level hire text in `chief-of-staff.md` lands with A5, when the inbox
decision exists. A contract sentence for a tool the chief doesn't have would
invite it to try.
- `HIRE_ALLOWED`: unchanged. `reviewer`, `security-reviewer` (`review.verdict`),
`pm` (`task.create`), `cto` and `merge-gate` stay unhireable. Making any of
them hireable is a separate vocabulary decision, not part of this proposal.
## 9. Phase A slices after the roles change is approved
1. **A2, root and project writers.** Owner-only root file writer (human
session, trail entry, revision check) and the project-agent writer verb
under the projects fence, both refusing every agent actor. Settings,
Agents gets the edit forms.
2. **A3, root copies run.** Each organization's bus boot materializes its
root copies as instances through `checkRoles`, with the organization's
credential bindings; unbound copies stay unlaunchable and say why. Project
agents launch only in their project.
3. **A4, chief hire and fire at org and project level**, bounded by the
approved `hireable` list, trailed.
4. **A5, root-level hire and fire as inbox decisions**, with the owner's
approval writing the root file through A2's writer.
5. **A6, the root assistant.** `<dataRoot>/assistant/` memory, the Console
read broker over `organizations.members`, and the per-organization
`assistant` instances retired in favor of the one root assistant.
6. **A7, the Agents grid's write controls** (model Change, override markers,
turn off for an organization), each a trailed human action.
## 10. Slice A1 as built
- **Store and union**: `packages/organization/src/agent-levels.mjs`, with the
old-path re-export `packages/business/src/agent-levels.mjs` that the rename
r2 stage B test requires of every organization module.
`validateBusinessDocument` checks `projects.<p>.agents` and drops it from
the loaded business.
- **Read**: `GET /api/agents/levels` (`packages/webui/src/agent-levels-read.mjs`),
wired in `cli.mjs` with `loadRoot` reading `<configDir>/agents.json`. The
route is instance-wide, like `/api/workspace`, so `display.js` leaves it
unscoped: its fetch wrapper would otherwise rewrite it to
`/api/orgs/<id>/agents/levels`, which does not exist.
- **Console editor**: `SERVER_PROJECT_KEYS` holds `agents`, so a browser draft
of the organization file keeps the server's value. The CLI's full-document
path (`mosaic org update --from`) is the owner's and can set project agents;
A1 adds no other writer.
- **Agents grid** (sample 08): the level band shows the root agents and their
per-organization opt-outs, each organization's instances, and each
project's own definitions. Below it, a project picker chooses which union
the table shows: agent, level, defined in, memory, status, model. Root and
project agents read "not running", because nothing launches them yet. The
root assistant's status reads "status not reported" and its model is the
one the root file sets, shown as configured. A
narrowed agent names the keys, an override names what it replaces, and a
line under the table lists what this project does not get and why. A
refused root file, organization or project shows its reason in place of
its rows. The grid has no write controls; those are A7.
+126 -20
View File
@@ -1,12 +1,11 @@
# PRD: Mosaic Stack
- Status: approved, version 1.19. Jason approved the exact text in Q289.
- Version 1.19 adds disk-space governance, launch-capacity holds,
human-controlled retention rules, and the protected `data.delete.policy`
route. It preserves per-batch `data.delete` hard waits. Jason approved
the exact text at `e5400456` under REQ-WAIT-2: "1.18: yes" (DECISIONS
Q289, relayed by Mos in mos-2300). Q288 approved the recurring-rule principle.
- Prior approval history, through version 1.18: Jason approved draft 0.4 unchanged as
- Status: approved, version 1.20. Jason approved draft 2 on 2026-11-09.
- Version 1.20 adds the unified workspace: every organization visible at once,
cross-organization views, three levels of agent configuration, per-org task
stores behind the task tracker kind, system type plug-ins and self-service
operation. It amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3.
- Prior approval history, through version 1.19: Jason approved draft 0.4 unchanged as
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
on 2026-10-14. They approved version 1.2, the chief of staff, the
@@ -64,7 +63,18 @@
the approved row 255 design. The stack prints the providers entry and
never writes config.json. What the requirement allows or forbids is
unchanged (mos-1671, sage-4418). Doc-only, so it takes the 1.11 path:
one seat drafts, a second reviews, Sage lands.
one seat drafts, a second reviews, Sage lands. Version 1.19 adds
disk-space governance, launch-capacity holds, human-controlled retention
rules, and the protected `data.delete.policy` route. It preserves per-batch
`data.delete` hard waits. Jason approved the exact text at `e5400456` under
REQ-WAIT-2: "1.18: yes" (DECISIONS Q289, relayed by Mos in mos-2300).
Q288 approved the recurring-rule principle. Jason approved the exact text in Q289.
- Version 1.20 source: Mos asked Jason to approve draft 2 in
`docs/plans/2026-11-09_prd-1-20-draft.md`, with the Montage name deferred.
Jason replied on 2026-11-09 at 22:57Z: "The file looks fine other than that."
The exception is the module name, which he had deferred at 19:26Z: "I don't
like these module names either. It isn't clicking for me. That may need to be
a later decision."
- Version 1.19 source, Jason's DECISIONS Q287, verbatim:
> All of the learnings from this drive space issue need to be integrated into Mosaic Stack. We need watchers, cleanups, enforcement tooling and agents to check for space waste, etc.
@@ -475,9 +485,11 @@ Added in version 1.2:
names the roles and how many of each. The chief of staff launches them
through role.launch, within the agent limits (REQ-LIMIT-1). (Version
1.5.)
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has
one assistant role per organization. It talks with the user, and it
passes work for the organization to the chief of staff. It doesn't
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has one
assistant across all of the organizations they
belong to. It knows the user's organizations, projects and sessions and
keeps one shared memory. It talks with the user and passes each
organization's work to that organization's chief of staff. It doesn't
direct other agents, create or assign tasks, or launch sessions.
- **REQ-ASSIST-2.** The broker limits the assistant's messages, not its
contract alone:
@@ -522,6 +534,24 @@ Added in version 1.7:
- Every hire and fire is logged with the chief's instance, its run and
the reason.
### Agent levels
Added in version 1.20.
- **REQ-AGENT-1.** Agents are configured at three levels: the user's root,
the organization and the project. A project's available agents are the
union of the three.
- **REQ-AGENT-2.** The user can see and change the configuration of agents,
organizations, projects and sessions, and can override any setting at any
time, through the write path (REQ-ROLE-3).
- **REQ-AGENT-3.** The chief of staff hires and fires agents to meet the
user's needs. At the organization and project levels it acts under
REQ-COS-3. At the root level its hire or fire changes the user's root
configuration, which every organization of that user sees, so it reaches
outside the chief's own organization. A root-level hire or fire is a
decision the user approves in the inbox (REQ-DEC-2) before it takes
effect. The user can also change root-level agents directly.
### Variables
- **REQ-VAR-1.** Variables come in four layers: system, business, project
@@ -879,8 +909,15 @@ consumed ordinary decision as standing permission.
### Tasks
- **REQ-TASK-1.** Tasks live in Vikunja, reached through `/api/v2` with a
bot token for each role. Each task cites a requirement id. Each task
- **REQ-TASK-1.** Each organization stores its own tasks, through the task tracker
plug-in kind (REQ-PLUG-1). The default module is a store built into Mosaic
Stack. Vikunja is an optional module, and when an organization selects it
the 1.19 rules for Vikunja (bot tokens per role, one writing role per field,
REQ-TASK-2 polling) apply to that organization. Every task tracker module
supports create, read, update, archive and delete, ordering and grouping.
Archiving or removing an organization removes its tasks without touching
another organization's.
Each task cites a requirement id. Each task
field has one writing role, listed in the slice 1 brief's field table,
and the assigned role writes the task's state. Since version 1.19, the
scoped disk-service principal in REQ-DISK-12 may initialize incident
@@ -1031,10 +1068,40 @@ Added in version 1.7:
- The decision event records via=console and the (iss, sub) pair.
- Without a signed-in session, the Console resolves nothing and shows
the `mosaic decide` line instead.
- **REQ-WEB-3.** The Console has controls to restart the bus and to
reload the organization file. Each control is a gated, trailed action.
A reload applies `launch.instances` changes to running sessions without
a manual `systemctl restart`. (Version 1.4, decision 021af0c8.)
- **REQ-WEB-3.** The Console has controls to restart itself, to restart an
organization's bus, to reload the organization file, and to change the model
an agent instance runs on and apply it. Each control is a gated, trailed
action that a human takes. No agent can take it. A reload applies
`launch.instances` changes to running sessions without a manual
`systemctl restart`. On a dev instance (`--dev-local-owner`) the controls
need no sign-in.
### Workspace
Added in version 1.20.
- **REQ-WS-1.** The WebUI shows every organization the user belongs to at
all times. A collapsible Organizations item in the sidebar lists them.
There is no organization switcher.
- **REQ-WS-2.** The Console reads across the buses of the user's
organizations to build shared views. Each organization keeps its own bus
as the tenant boundary (REQ-ORG-1). A shared view shows only
organizations the user belongs to.
- **REQ-WS-3.** The main dashboard sums activity, work and metrics across
all of the user's organizations and projects.
- **REQ-WS-4.** Each organization has a dashboard with its docs, every
configurable setting, its KPIs and metrics, and its active agent
sessions.
- **REQ-WS-5.** The projects list covers every organization. It sorts and
filters by organization, status, name and activity. Each project shows
its owning organization by icon, color and avatar.
- **REQ-WS-6.** One inbox holds every item that needs the user, from every
organization, project and session.
- **REQ-WS-7.** One view shows activity across all organizations and
projects. Organizations and projects can be open side by side.
- **REQ-WS-8.** The tasks view aggregates every organization's tasks, with
Kanban and list layouts, filtered by organization and project. It works
the same for every task tracker module.
### Launching
@@ -1173,6 +1240,17 @@ Added in version 1.3 (Q155).
changes it, not in one rewrite (Q155). Until it converts, Technical
considerations lists where it is hard-coded.
### System types
Added in version 1.20.
- **REQ-SYS-1.** System types are a plug-in kind. Its modules include
coding, creative writing, marketing, Mosaic Montage (the OpenMontage
fork) and business management. An organization enables a list of them.
- **REQ-SYS-2.** A project may use any system type its organization
enables, and none is required. An agent working in a project gets the
toolsets of the system types that project uses.
### Meta-harness
- **REQ-HARN-1.** The meta-harness generates each session's launch bundle
@@ -1329,9 +1407,13 @@ Added in version 1.1 (decision 03130d08).
- **REQ-ORG-2.** The data model is install, then organization, then
projects, then threads, so v1 doesn't lock in one human per
organization.
- **REQ-USER-1.** Users and role-based access control exist at install
and organization level. An agent belongs to one organization and
reaches only its allowed projects and threads.
- **REQ-USER-1.** Users and role-based access control exist at install and
organization level. An agent other than the assistant works in one
organization at a time. Its memory, files and credentials are scoped to that
organization, even when its definition comes from the user's root
configuration. It reaches only that organization's allowed projects and
threads. The assistant is the exception in REQ-ASSIST-1, and it reaches only
organizations its user belongs to.
- **REQ-USER-2.** Only the user who owns a thread reads it or writes to
it, plus that user's assistant and the chief of staff's replies on work
the thread sent. This fills in REQ-USER-1's "threads" for the chat
@@ -1462,6 +1544,15 @@ design `docs/plans/2026-10-21_t1-sessions.md`, 2c843d190 on `huey/t1-sessions-de
with `processing-halted` and changes nothing. A clear never sets or
clears the kill switch. (Design D8, test T6.)
### Accounts
Added in version 1.20.
- **REQ-ACCT-1.** A deployed system requires user sign-in (OIDC, Q203). A
user creates organizations and belongs to any number of them.
- **REQ-ACCT-2.** An organization's owner invites another user into it.
The invited user joins with the grant the owner chose (REQ-USER-3).
### Creative library
Added in version 1.12 (Q176 Case 2, Q211). Drafted from the Case 2
@@ -1788,6 +1879,17 @@ Added in version 1.11 (Q187, Q189, Q211):
monitored coverage. Install and upgrade enable no deletion policies
(REQ-DISK-1, REQ-DISK-4, REQ-DISK-10).
### Setup
Added in version 1.20.
- **REQ-SETUP-1.** Quick Setup leaves its project with its records
initialized.
- **REQ-SETUP-2.** After Quick Setup, the assistant offers an onboarding
interview. It asks for the project's outcome and limits and drafts the
north star and a first PRD. The user approves each through the existing
approval path.
## Acceptance criteria and success measures
v1 is done when Jason gives one-sentence requests to the assistant, in a
@@ -2367,6 +2469,10 @@ gate that slice.
## Risks and open questions
- The final name for the Mosaic Montage module is deferred to a later owner
decision. Mosaic Montage remains a working name; the existing four-tile
logo stays. (Version 1.20.)
- On each v1 instance, the stack runs as that instance's own OS user,
without sudo (Q181). A managed role session runs in a sandbox
(REQ-HARN-2): it writes only its workspace and run directory, sees no
+11
View File
@@ -1117,3 +1117,14 @@ For lifecycle tasks, the first review request includes the exact raw JSON
verification-document and verdict-body contract; unrelated reviews retain their
existing free-text contract. The typed record tools describe the existing closed
requirement schema without relaxing host validation.
## Built-in task store writes
The outside-agent human session can call `tasks.create` with `title` and a `requirement` ID
(such as `REQ-TASK-1`), plus optional task fields. `tasks.edit` accepts `task_ref` and the
fields to change, including `requirement`; archive, unarchive, and delete take `task_ref`.
Each write records `human.input`. A task read returns a `digest` that can be supplied as
`expect` to a later write; any intervening revision, including archive or unarchive, refuses
`task-conflict`. Existing assignees may remain after a role is removed, but new assignees
must be current roles. Automatic tail placement and an explicitly occupied position refuse
`task-position-full` when there is no room; no task row is written.
+57
View File
@@ -639,3 +639,60 @@ CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snaps
CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_delete BEFORE DELETE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
-- Per-project selections are a human-authored append-only settings trail.
CREATE TABLE project_system_types (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
types TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+426 -13
View File
@@ -95,7 +95,8 @@ export const REPLIES = 50;
// Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first.
export const STRANDED = 50;
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot'];
// U4a (#1873): `archived` reads the built-in store's archived tasks; a Vikunja task is never archived.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot', 'archived'];
export const TASK_PAGE = 50;
export const TASK_PAGE_MAX = 200;
// D3 (#797): raw UTF-8 pre-bound for the approved requirement texts quoted into one coder/reviewer
@@ -165,7 +166,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -184,6 +185,8 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
// Row 275 (row 193 design 4.3): the owner's board reads, which a reader session may call. A user,
// collaborator or viewer, is refused every one at the USER_VERBS gate.
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
// The person's task-store writes, distinct from the agent's singular task.* actions.
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -295,6 +298,105 @@ function identifier(x) {
return x;
}
const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x);
// U4a (#1873, REQ-TASK-1): the task store built into each organization, the default task-tracker
// module. Its rows live in the organization's own bus.sqlite (store_tasks, migration 3u-3v). These
// shapes are exported for packages/tasks/src/store.mjs, the module, so the broker and the module
// share one definition; they stay in this file because its import list is the sandbox's closure.
// A task's fields, whole after every write:
// title, description text, as the Vikunja verbs take them
// due_date canonical ISO 8601 UTC with milliseconds, or null
// priority 0 (unset) to 5
// percent_done 0 to 1
// state todo, in-progress, in-review, blocked or done
// bucket, done what state implies, under the names the Console and `mosaic tasks` read
// labels positive label ids, sorted, at most 50
// assignees role instance names, sorted, at most 50
// group null or a short name: the column or list the task sits in
// position a whole number ordering tasks inside their group
// comments, comment how many comments, and the latest ({ text, by })
// verdict the review verdict a close cited
// request, op what a role's create cited; null on a person's
// requirement the requirement cited on create (or edited by a person)
export const STORE_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
// The states task.update.assigned moves between; done is task.close's (slice 1 field table).
export const ASSIGNED_STATES = Object.freeze(STORE_STATES.filter((x) => x !== 'done'));
// A built-in task's ref is its number in the organization, never reused.
const STORE_REF = /^task:([1-9][0-9]{0,14})$/;
export const storeRef = (x) => typeof x === 'string' && STORE_REF.test(x);
export const storeTaskNumber = (x) => (storeRef(x) ? Number(STORE_REF.exec(x)[1]) : null);
export const STORE_POSITION_MAX = 1_000_000_000;
// What a person's tasks.create and tasks.edit may set. Roles write through the eight task verbs,
// each held to the fields the field table gives it.
export const STORE_EDIT_FIELDS = Object.freeze(['title', 'description', 'due_date', 'priority', 'percent_done', 'state', 'labels', 'assignees', 'group', 'position', 'requirement']);
const isoMillis = (x) =>
typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x;
// One check per field: the raw value in, the stored value out, or a refusal.
export function storeFieldChecks(roles) {
const str = (x, max, min = 0) => {
if (typeof x !== 'string' || x.length < min || x.length > max || x.includes('\0')) fail('invalid-request');
return x;
};
const set = (x, item, order) => {
if (!Array.isArray(x) || x.length > 50) fail('invalid-request');
return [...new Set(x.map(item))].sort(order);
};
return {
title: (x) => (str(x, 250, 1).trim() ? x : fail('invalid-request')),
description: (x) => str(x, 65536),
due_date: (x) => (x === null || isoMillis(x) ? x : fail('invalid-request')),
priority: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= 5 ? x : fail('invalid-request')),
percent_done: (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1 ? x : fail('invalid-request')),
state: (x) => (STORE_STATES.includes(x) ? x : fail('invalid-state')),
labels: (x) => set(x, (l) => (Number.isSafeInteger(l) && l >= 1 ? l : fail('invalid-request')), (a, b) => a - b),
assignees: (x) => set(x, (r) => (typeof r === 'string' && Object.hasOwn(roles, r) ? r : fail('unknown-role'))),
group: (x) => (x === null ? null : str(x, 64, 1).trim() && !/[\u0000-\u001f\u007f]/.test(x) ? x : fail('invalid-request')),
position: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= STORE_POSITION_MAX ? x : fail('invalid-request')),
requirement: (x) => (typeof x === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(x) ? x : fail('invalid-request')),
};
}
// Checked edits on a copy of the fields. A state sets the bucket and done it implies. A move to
// another group with no position goes to the end of that group (a null position, which the write settles).
export function storeApply(fields, edits) {
const out = { ...fields, ...edits };
if (Object.hasOwn(edits, 'state')) Object.assign(out, { bucket: edits.state, done: edits.state === 'done' });
if (Object.hasOwn(edits, 'group') && !Object.hasOwn(edits, 'position') && edits.group !== fields.group) out.position = null;
return out;
}
// A new task's fields. A null position means after the last task in its group; the write settles it.
export function storeNewTask({ title, request = null, requirement = null, op = null }) {
return {
title, description: '', due_date: null, priority: 0, percent_done: 0, state: 'todo', bucket: 'todo', done: false,
labels: [], assignees: [], group: null, position: null, comments: 0, comment: null, verdict: null, request, requirement, op,
};
}
// Every field a stored task has, no more and no fewer.
export const STORE_FIELDS = Object.freeze(Object.keys(storeNewTask({ title: 'x' })));
// The fields each role verb may change: the slice 1 field table (packages/tasks/fields.json) on the
// store's fields. The table's labels and due date go with task.schedule, so the store's group and
// position do too; a comment's count goes with the latest comment; a close records its verdict; and
// a create sets what it cited. The broker refuses a role's write that changes any other field
// (field-writer), whatever the module asked for.
export const STORE_VERB_FIELDS = Object.freeze({
'task.create': Object.freeze(['title', 'description', 'due_date', 'priority', 'labels', 'group', 'position', 'request', 'requirement', 'op']),
'task.assign': Object.freeze(['assignees']),
'task.reassign': Object.freeze(['assignees']),
'task.schedule': Object.freeze(['due_date', 'labels', 'group', 'position']),
'task.priority.change': Object.freeze(['priority']),
'task.scope.change': Object.freeze(['title', 'description']),
'task.update.assigned': Object.freeze(['percent_done', 'state', 'bucket', 'comments', 'comment']),
'task.close': Object.freeze(['state', 'bucket', 'done', 'verdict']),
});
// Gaps between settled positions, so a person can place a task between two others.
export const STORE_POSITION_STEP = 1024;
// The most one task's fields take, as JSON.
const STORE_TASK_BYTES = 262144;
// Board order: ungrouped first, then group name, position and task number. Every read sorts this way.
export function storeOrder(a, b) {
const ga = a.group ?? '', gb = b.group ?? '';
if (ga !== gb) return ga < gb ? -1 : 1;
if ((a.position ?? 0) !== (b.position ?? 0)) return (a.position ?? 0) - (b.position ?? 0);
return a.task - b.task;
}
// A git commit id, SHA-1 or SHA-256.
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
@@ -544,8 +646,9 @@ export class Broker {
#accessRevoked = new Set();
#clock = 0;
#processing;
#taskTracker;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -555,6 +658,7 @@ export class Broker {
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
if (compactions !== null && typeof compactions !== 'function') throw new TypeError('compactions must be a function');
if (taskTracker !== null && typeof taskTracker !== 'function') throw new TypeError('taskTracker must be a function');
this.#businesses = structuredClone(businesses);
this.#store = store;
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
@@ -596,6 +700,11 @@ export class Broker {
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
// stands, for the hire checks. Without it a hire refuses not-configured.
this.#roleFile = roleFile;
// U4a (#1873): taskTracker(business) names the task-tracker module the bus runtime chose for the
// business at start. The store's writes refuse tracker-not-store under any other module, so a
// Vikunja organization never grows a second task list. Without it (the unit tests' brokers)
// every business uses the store.
this.#taskTracker = taskTracker ?? (() => 'store');
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements();
// These are host capabilities, like project/library; no filesystem module enters the client closure.
@@ -1104,6 +1213,12 @@ export class Broker {
this.#businesses[name] = next;
this.#scheduleRequests();
}
// Host-only current role resolver shared by the broker and built-in task tracker.
taskRoles(business) {
const roles = this.#business(business).roles;
if (!object(roles)) fail('invalid-business');
return structuredClone(roles);
}
// Lifecycle Task 2 (#797): the chat project a bootstrapped product lists under. Host-only, so no verb
// reaches it. State is 'free', 'associated' (this exact id, name and product) or 'conflict' (anything else
// holding the id or the name), which lets a retry insert the row once.
@@ -4632,11 +4747,16 @@ export class Broker {
// the short form in task_ref order: no description, and the assignees that are a role's tracker
// bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts
// every task the filters match; `next` is the `after` for the next page, null on the last.
// U4a (#1873, REQ-WS-8): the store's tasks come after the Vikunja rows, in board order, in the same
// shape (task_ref, fields, digest, priority, requirement, at), with source 'store'. An archived store
// task leaves the no-argument read and the filtered read unless `archived: true` asks for archived
// tasks; `task_ref` alone still gives it, with archived true.
#tasks(business, a) {
const rows = this.#taskRows(business);
if (!Object.keys(a).length) return rows;
const rows = this.#taskRows(business), store = this.#storeRows(business);
if (!Object.keys(a).length) return [...rows, ...store.filter((r) => !r.archived)];
if ('task_ref' in a) {
if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request');
if (Object.keys(a).length !== 1 || !(taskRef(a.task_ref) || storeRef(a.task_ref))) fail('invalid-request');
if (storeRef(a.task_ref)) return this.#storeCurrent(business, a.task_ref);
return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found');
}
const roles = this.#business(business).roles ?? {};
@@ -4649,7 +4769,8 @@ export class Broker {
('project_key' in a && !projectKey(a.project_key)) ||
('requirement_snapshot' in a && !id(a.requirement_snapshot)) ||
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
('after' in a && !taskRef(a.after))
('archived' in a && typeof a.archived !== 'boolean') ||
('after' in a && !(taskRef(a.after) || storeRef(a.after)))
)
fail('invalid-request');
const bots = new Map(
@@ -4680,10 +4801,209 @@ export class Broker {
(!('project_key' in a) || t.project_key === a.project_key) &&
(!('requirement_snapshot' in a) || t.requirement_snapshot === a.requirement_snapshot),
);
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
// The store's short form adds its group, position and archived flag; its assignees are role names.
const archived = a.archived ?? false;
const filed = store
.filter((r) => r.archived === archived)
.map(({ task_ref, fields: f, priority, requirement, at, archived: gone }) => ({
task_ref,
title: f.title,
done: f.done,
bucket: f.bucket,
assignees: [...f.assignees],
labels: [...f.labels],
priority,
requirement,
changed_at: at,
group: f.group,
position: f.position,
archived: gone,
}))
.filter(
(t) =>
(!('done' in a) || t.done === a.done) &&
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
(!('label' in a) || t.labels.includes(a.label)) &&
(!('requirement' in a) || t.requirement === a.requirement) &&
!('project_key' in a) &&
!('requirement_snapshot' in a),
);
// Archived tasks are store tasks only. Pages run through the Vikunja tasks, then the store's: a
// store `after` skips every Vikunja task and the store tasks up to it in board order. A cursor whose
// task moved since continues from where it now stands; one whose task was deleted reads its last row.
const all = [...(archived ? [] : matched), ...filed];
let rest = all;
if (taskRef(a.after ?? null)) rest = all.filter((t) => !taskRef(t.task_ref) || t.task_ref > a.after);
else if ('after' in a) {
const n = storeTaskNumber(a.after);
const last = this.#store.get('SELECT fields FROM store_tasks WHERE business=? AND task=? ORDER BY seq DESC LIMIT 1', business, n);
if (!last) fail('task-not-found');
const f = JSON.parse(last.fields), at = { group: f.group, position: f.position, task: n };
rest = filed.filter((t) => storeOrder({ group: t.group, position: t.position, task: storeTaskNumber(t.task_ref) }, at) > 0);
}
const limit = a.limit ?? TASK_PAGE;
const tasks = rest.slice(0, limit);
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
return { tasks, total: all.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
}
// U4a (#1873, REQ-TASK-1): the built-in task store. A task is its latest store_tasks row, and it is
// archived when its latest archive or unarchive row is an archive. A deleted task's latest row is
// its delete, so it leaves every read here. Every read builds from the rows; nothing is cached.
#storeLatest(business, task = null) {
return this.#store
.all(
`SELECT t.*, (SELECT a.op FROM store_tasks a WHERE a.business=t.business AND a.task=t.task AND a.op IN ('archive','unarchive') ORDER BY a.seq DESC LIMIT 1) AS archive
FROM store_tasks t WHERE t.business=?${task === null ? '' : ' AND t.task=?'}
AND t.seq=(SELECT max(seq) FROM store_tasks WHERE business=t.business AND task=t.task) AND t.op<>'delete' ORDER BY t.task`,
...(task === null ? [business] : [business, task]),
)
.map((r) => {
const fields = JSON.parse(r.fields);
// The keys a Vikunja `tasks` row has that the Console and `mosaic tasks` read (task_ref,
// fields, digest, priority, requirement, at, seq, source), with source 'store', and the
// store's own: revision, the verb that wrote it, who (role and run, or the person), the event.
return {
seq: r.seq, at: r.at, business: r.business, task_ref: `task:${r.task}`, tracker: 'store', source: 'store',
updated: r.at, revision: r.revision, verb: r.verb, role: r.by_role, run: r.by_run, human: r.by_human, event: r.event,
digest: createHash('sha256').update(JSON.stringify([r.revision, r.fields])).digest('hex'), fields,
priority: fields.priority ?? null, requirement: fields.requirement ?? null, archived: r.archive === 'archive',
};
});
}
// The live tasks, archived ones too, in board order.
#storeRows(business) {
const at = (r) => ({ group: r.fields.group, position: r.fields.position, task: storeTaskNumber(r.task_ref) });
return this.#storeLatest(business).sort((a, b) => storeOrder(at(a), at(b)));
}
#storeOn(business) {
this.#business(business);
if (this.#taskTracker(business) !== 'store') fail('tracker-not-store');
}
#storeCurrent(business, ref) {
const n = storeTaskNumber(ref);
if (n === null) fail('invalid-request');
return this.#storeLatest(business, n)[0] ?? fail('task-not-found');
}
// A role's create cites the person's instruction that asked for it, as a Vikunja create does.
#storeRequest(business, request) {
return (
typeof request === 'string' &&
!!this.#store.get(
"SELECT 1 FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL AND json_extract(body,'$.kind')='instruction'",
business,
request,
)
);
}
// The whole task a write leaves: every field the store keeps and no other, each already in the form
// its check gives (labels and assignees sorted, no repeats), and the bucket and done its state implies.
#storeFields(business, f, previous) {
if (!object(f) || Object.keys(f).length !== STORE_FIELDS.length || !STORE_FIELDS.every((k) => Object.hasOwn(f, k))) fail('invalid-request');
const roles = { ...this.taskRoles(business), ...Object.fromEntries((previous?.assignees ?? []).map(role => [role, true])) };
const check = storeFieldChecks(roles);
for (const k of STORE_EDIT_FIELDS) {
if (k === 'requirement' && f[k] === null) continue; // historical tasks
if (JSON.stringify(check[k](f[k])) !== JSON.stringify(f[k])) fail('invalid-request');
}
const text = (x, max) => x === null || (typeof x === 'string' && x.length >= 1 && x.length <= max && !x.includes('\0'));
if (
f.bucket !== f.state ||
f.done !== (f.state === 'done') ||
!(Number.isSafeInteger(f.comments) && f.comments >= 0) ||
!(f.comment === null || (object(f.comment) && Object.keys(f.comment).length === 2 && text(f.comment.text, 10000) && f.comment.text !== null && Object.hasOwn(f.comment, 'by') && text(f.comment.by, 128) && f.comment.by !== null)) ||
!text(f.verdict, 256) ||
!text(f.request, 64) ||
!(f.requirement === null || (typeof f.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(f.requirement))) ||
!text(f.op, 256)
)
fail('invalid-request');
}
// Appends one row and the event that records it, and answers the task as it now reads (or that it
// was deleted). The caller owns the transaction and has checked who may write. A null position
// settles after the last task in the task's group.
#storeWrite(s, { current, op, verb, fields, by, kind, body }) {
const task = current
? storeTaskNumber(current.task_ref)
: (this.#store.get('SELECT max(task) AS n FROM store_tasks WHERE business=?', s.business).n ?? 0) + 1;
const ref = `task:${task}`;
const peers = this.#storeRows(s.business).filter((r) => r.task_ref !== ref && (r.fields.group ?? null) === (fields.group ?? null));
if (fields.position === null) {
const last = peers.reduce((m, r) => Math.max(m, r.fields.position ?? 0), -STORE_POSITION_STEP);
if (last + STORE_POSITION_STEP > STORE_POSITION_MAX) fail('task-position-full');
fields = { ...fields, position: last + STORE_POSITION_STEP };
} else if ((!current || fields.group !== current.fields.group || fields.position !== current.fields.position) && peers.some(r => r.fields.position === fields.position)) {
fail('task-position-full');
}
this.#storeFields(s.business, fields, current?.fields);
const json = JSON.stringify(fields);
if (Buffer.byteLength(json) > STORE_TASK_BYTES) fail('invalid-request');
this.#secretCheck(fields);
const event = this.#event(s, kind, { ...body, task_ref: ref, tracker: 'store', op, verb }, ref);
this.#store.run(
'INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
this.#now(),
s.business,
task,
current ? current.revision + 1 : 1,
op,
verb,
by.role,
by.run,
by.human,
event,
json,
);
return op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
}
// U4a: the store module's one write path for a role, as authorize() and recordTask() are the
// Vikunja adapter's. One transaction holds the holder check, the authority (a decision is spent here
// as #consumeAuthority spends it), a close's verdict check, the event and the row. `change` is the
// module's: it gets a copy of the task's fields (a new task's on a create) and of its row (null on a
// create) and returns the whole new fields. The write may change only what STORE_VERB_FIELDS gives
// its verb. An archived task takes no role write; a person unarchives it first. A create that names
// an `op` the same role already created with answers that task again, with nothing written; the same
// op with other fields, or from another role, refuses (task-create-conflict).
storeTask(cap, { verb, task_ref = null, decision = null, expect = null, verdict = null, change } = {}) {
const s = this.#session(cap);
if (!TASK_VERBS.includes(verb) || typeof change !== 'function' || (verb === 'task.create') !== (task_ref === null)) fail('invalid-request');
return this.#store.transaction(() => {
this.#agent(s);
this.#storeOn(s.business);
const current = task_ref === null ? null : this.#storeCurrent(s.business, task_ref);
if (current?.archived) fail('task-archived');
if (expect !== null && current && expect !== current.digest) fail('task-conflict');
const before = current?.fields ?? storeNewTask({ title: null }), allowed = STORE_VERB_FIELDS[verb];
const fields = change(structuredClone(before), current && structuredClone(current));
if (!object(fields)) fail('invalid-request');
for (const k of new Set([...Object.keys(before), ...Object.keys(fields)]))
if (JSON.stringify(before[k]) !== JSON.stringify(fields[k]) && !allowed.includes(k)) fail('field-writer');
if (!current && !this.#storeRequest(s.business, fields.request)) fail('request-not-found');
if (!current && typeof fields.op === 'string') {
const old = this.#store.get(
"SELECT task, by_role, fields FROM store_tasks WHERE business=? AND op='create' AND json_extract(fields,'$.op')=? ORDER BY seq LIMIT 1",
s.business,
fields.op,
);
if (old) {
const was = JSON.parse(old.fields), same = (k) => JSON.stringify(was[k]) === JSON.stringify(fields[k]);
if (old.by_role !== s.role || !STORE_FIELDS.every((k) => same(k) || (k === 'position' && fields.position === null))) fail('task-create-conflict');
this.#checkAuthority(s, verb, { decision, target: null });
const ref = `task:${old.task}`;
return this.#storeLatest(s.business, old.task)[0] ?? { task_ref: ref, deleted: true };
}
}
const authority = this.#checkAuthority(s, verb, { decision, target: task_ref });
if (authority.decision && this.#spent(s.business, authority.decision)) fail('decision-consumed');
if (verb === 'task.close') this.#closeVerdict(s, task_ref, verdict);
return this.#storeWrite(s, {
current,
op: current ? 'change' : 'create',
verb,
fields,
by: { role: s.role, run: s.run, human: null },
kind: 'action.allowed',
body: { action: verb, ...authority, target: task_ref, ...(verb === 'task.close' ? { verdict } : {}) },
});
});
}
#checkAuthority(s, action, context = {}) {
this.#processingWork();
@@ -5460,16 +5780,18 @@ export class Broker {
}
#refused(s, e, request, other = 'storage-refused') {
const error = e instanceof BusError ? e : new BusError(other);
// No request content or raw exception text in refusal evidence.
// No request content or raw exception text in refusal evidence. U4a: a built-in store task's
// ref files the refusal on that task's trail too.
const subject = (x) => taskRef(x) || storeRef(x);
try {
this.#store.transaction(() =>
this.#event(
s,
'action.refused',
{ code: error.code },
taskRef(request?.args?.task_ref)
subject(request?.args?.task_ref)
? request.args.task_ref
: taskRef(request?.args?.target)
: subject(request?.args?.target)
? request.args.target
: null,
),
@@ -5560,6 +5882,28 @@ export class Broker {
return result;
}).catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); });
}
if (request.verb === 'system-types.console') {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
keys(args, ['project']);
if (!this.#organization) fail('not-configured');
const enabled = this.#organization(s.business, 'system-types.current', {}, s.human);
if (!Array.isArray(enabled)) fail('not-configured');
let project = null;
if (args.project !== undefined) {
const id = identifier(args.project);
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', id, s.business)) fail('project-not-found');
const row = this.#store.get('SELECT types FROM project_system_types WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', s.business, id);
const selected = row ? JSON.parse(row.types) : [];
project = { project: id, selected, active: selected.filter(x => enabled.includes(x)), inactive: selected.filter(x => !enabled.includes(x)) };
}
return Promise.resolve(this.#organization(s.business, 'system-types.installed', {}, s.human))
.then(installed => {
if (!Array.isArray(installed)) fail('not-configured');
const result = { installed, enabled, project };
this.#secretCheck(result);
return result;
});
}
if (request.verb === 'project.remote.get.console' || request.verb === 'project.remote.set.console') {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
if (!this.#organization) fail('not-configured');
@@ -5666,7 +6010,7 @@ export class Broker {
if (["limits", "plugins", "instances", "routes", "organization.revision"].includes(request.verb)) keys(args, []);
else if (request.verb === "plugins.set") {
keys(args, ["revision", "plugins", "usage"], ["revision", "plugins"]);
if (s.tester && object(args.plugins) && Object.hasOwn(args.plugins, 'model-provider')) fail('human-required');
if (s.tester && object(args.plugins) && (Object.hasOwn(args.plugins, 'model-provider') || Object.hasOwn(args.plugins, 'system-type'))) fail('human-required');
}
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
// organization.set and the chief of staff's tool never reach it (sage-0918).
@@ -6573,6 +6917,8 @@ export class Broker {
#dispatch(s, verb, a, spec = null, binding = null) {
const b = this.#business(s.business),
by = s.human || s.run;
// Preserve the common request gates and refuse additions until the vocabulary names them.
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
switch (verb) {
case 'role.claim': {
keys(a, []);
@@ -7566,10 +7912,64 @@ export class Broker {
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null))
.map((p) => (p.state === 'archive' ? { id: p.id, at: p.at, name: p.name, repo: p.repo, archived: true } : { id: p.id, at: p.at, name: p.name, repo: p.repo }));
}
case 'project.system-types':
case 'project.system-types.set': {
this.#human(s);
const write = verb === 'project.system-types.set';
keys(a, write ? ['project', 'systemTypes'] : ['project'], write ? ['project', 'systemTypes'] : ['project']);
const project = identifier(a.project);
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', project, s.business)) fail('project-not-found');
// When configured, read the trusted file each time; a stale bus snapshot must not reactivate a disabled type.
const enabled = this.#organization ? this.#organization(s.business, 'system-types.current', {}, s.human) : (b.plugins?.['system-type'] ?? []);
if (!Array.isArray(enabled)) fail('not-configured');
if (write) {
if (!Array.isArray(a.systemTypes) || a.systemTypes.some(x => typeof x !== 'string') || new Set(a.systemTypes).size !== a.systemTypes.length) fail('invalid-request');
if (a.systemTypes.some(x => !enabled.includes(x))) fail('system-type-not-enabled');
this.#store.run('INSERT INTO project_system_types(at,business,project,types,by) VALUES(?,?,?,?,?)', this.#now(), s.business, project, JSON.stringify(a.systemTypes), this.#owner(s));
}
const row = this.#store.get('SELECT types FROM project_system_types WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', s.business, project);
const selected = row ? JSON.parse(row.types) : [];
return { project, selected, active: selected.filter(x => enabled.includes(x)), inactive: selected.filter(x => !enabled.includes(x)) };
}
// #797 MVP3 (owner report 2026-11-06): the owner's reversible retirement of a project. The
// human gate is project.create's minus the tester carve-out: tester, user and agent sessions
// are refused here. Each verb appends one project_archives row (the audit trail) and deletes
// nothing; a repeat of the state the project already holds refuses.
// U4a (#1873, REQ-TASK-1): the person's own writes to the built-in task store, from the terminal
// (human-cli). Each writes a human.input event (body kind 'task') that its store_tasks row cites, so a
// person's edit comes in as an event like any other input. The person writes any field, may reopen a
// done task, and is the only one who archives, unarchives or deletes. `expect` is the digest the
// person last read; a later write refuses task-conflict.
case 'tasks.create':
case 'tasks.edit':
case 'tasks.archive':
case 'tasks.unarchive':
case 'tasks.delete': {
this.#human(s);
this.#storeOn(s.business);
const creating = verb === 'tasks.create', editing = verb === 'tasks.edit';
keys(a, creating ? STORE_EDIT_FIELDS : editing ? ['task_ref', 'expect', ...STORE_EDIT_FIELDS] : ['task_ref', 'expect'], creating ? ['title', 'requirement'] : ['task_ref']);
if (Object.hasOwn(a, 'expect') && !(typeof a.expect === 'string' && /^[0-9a-f]{64}$/.test(a.expect))) fail('invalid-request');
const current = creating ? null : this.#storeCurrent(s.business, a.task_ref);
const roles = { ...this.taskRoles(s.business), ...Object.fromEntries((current?.fields.assignees ?? []).map(role => [role, true])) };
const check = storeFieldChecks(roles), edits = {};
for (const k of STORE_EDIT_FIELDS) if (Object.hasOwn(a, k)) edits[k] = check[k](a[k]);
if (editing && !Object.keys(edits).length) fail('invalid-request');
if (current && Object.hasOwn(a, 'expect') && a.expect !== current.digest) fail('task-conflict');
if (editing && current.archived) fail('task-archived');
if (verb === 'tasks.archive' && current.archived) fail('task-already-archived');
if (verb === 'tasks.unarchive' && !current.archived) fail('task-not-archived');
const fields = creating ? storeApply(storeNewTask({ title: edits.title }), edits) : editing ? storeApply(current.fields, edits) : current.fields;
return this.#storeWrite(s, {
current,
op: creating ? 'create' : editing ? 'change' : verb.slice('tasks.'.length),
verb,
fields,
by: { role: null, run: null, human: this.#owner(s) },
kind: 'human.input',
body: { kind: 'task' },
});
}
case 'project.archive':
case 'project.unarchive': {
this.#human(s);
@@ -8350,6 +8750,19 @@ export class Broker {
);
}
}
// U4a (#1873): a store task's rows (its whole history, a delete included) and the person's
// instruction a role's create cited, with that instruction's message.
if (storeRef(subject)) {
const own = this.#store.all('SELECT * FROM store_tasks WHERE business=? AND task=? ORDER BY seq', s.business, storeTaskNumber(subject));
add('store_tasks', own);
const request = own.length ? JSON.parse(own[0].fields).request : null;
const input = request && this.#store.get("SELECT * FROM events WHERE business=? AND kind='human.input' AND id=?", s.business, request);
if (input) {
add('events', [input]);
const body = JSON.parse(input.body);
if (body.message) add('messages', this.#store.all('SELECT * FROM messages WHERE business=? AND id=?', s.business, body.message));
}
}
for (const m of [...rows.values()])
if (m.table === 'messages')
add('deliveries', this.#store.all('SELECT * FROM deliveries WHERE message=?', m.id));
+16
View File
@@ -0,0 +1,16 @@
-- Per-project selections are a human-authored append-only settings trail.
CREATE TABLE project_system_types (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
types TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
+41
View File
@@ -0,0 +1,41 @@
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+60 -5
View File
@@ -1,7 +1,7 @@
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs';
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
import { lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
@@ -22,6 +22,7 @@ import { readSessions, sessionsFile, stopSession } from '../../seat/src/session.
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { ProxyPage } from './proxy-page.mjs';
@@ -62,6 +63,8 @@ export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
// U4a (#1873): it serves the businesses whose task-tracker module is Vikunja; the others get theirs
// through the plug-in loader, the built-in store by default (see trackerOf in startBroker).
// REQ-HARN-2, REQ-PLUG-2 (decision 0b81b0c5): one web service per business. Its search source is
// the "search-source" plug-in the business's organization file switches on: searxng-url (the
// business var web.searxng) or searxng-bundled. With none, web.search refuses "not configured" and
@@ -360,6 +363,11 @@ export async function startBroker({
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
// any reference names the store. A root that exists with loose modes or as a symlink still refuses.
if (storeRoot && Object.values(references).some(refs => Object.values(refs ?? {}).some(ref => ref?.store === true)))
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
if (proxy) {
@@ -647,7 +655,21 @@ export async function startBroker({
return no(declared ? 'project-pending' : 'project-unknown');
}
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1): the task-tracker module each business uses, fixed for this
// start. The organization file's "task-tracker" plug-in names it. With none, a business the boot
// config gives a Vikunja tracker keeps Vikunja (so does every business when an in-process caller
// hands `tasks` and no `trackers`), and any other business gets the built-in store. A business that
// names another module while the boot config also gives it a Vikunja tracker refuses the boot:
// two trackers can't both hold its tasks.
const trackerOf = new Map();
for (const [id, b] of Object.entries(businesses)) {
const named = b?.plugins?.['task-tracker'];
const tracked = trackers !== null && typeof trackers === 'object' && Object.hasOwn(trackers, id);
if (named !== undefined && named !== 'vikunja' && tracked) throw new BusError('tracker-config');
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
}
broker = new Broker({
taskTracker: (id) => trackerOf.get(id),
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
requestLifecycle: services => {
@@ -746,6 +768,8 @@ export async function startBroker({
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if (verb === 'system-types.installed') return installedSystemTypes();
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
@@ -880,11 +904,42 @@ export async function startBroker({
}
const bound = launches.map(bindLaunch);
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
if (tasks) {
adapter = await tasks({ broker, credentials, businesses, processing });
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
// U4a: one adapter per task-tracker module in use, behind one handle that routes each call by the
// caller's business. The host's `tasks` factory is the Vikunja module. Any other module, and Vikunja
// when the host gave no factory but an organization file names it, loads through the plug-in loader
// with the boot config's `trackers`. A module that refuses to load or has no factory refuses
// startup with tracker-config. A module whose factory makes no
// adapter refuses the boot (invalid-adapter), and every adapter made so far is closed. The host's
// factory is always made when given, used or not, so its own checks of `trackers` still run.
const made = new Map();
adapter = { async close() { for (const a of made.values()) await a?.close?.(); } };
const pluginLog = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`);
for (const name of new Set([...(tasks ? ['vikunja'] : []), ...trackerOf.values()])) {
let factory = null;
if (name === 'vikunja' && tasks) factory = tasks;
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
const loader = createLoader({ log: pluginLog });
const { refused } = await loader.load({ 'task-tracker': name });
const [module] = loader.modules('task-tracker');
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
}
// A host may omit its implicit Vikunja adapter, retaining the legacy unconfigured path.
// An explicitly selected module must always supply a factory.
if (!factory && name === 'vikunja' && !tasks && !Object.values(businesses).some(b => b?.plugins?.['task-tracker'] === 'vikunja')) continue;
if (typeof factory !== 'function') throw new BusError('tracker-config');
const one = await factory({ broker, credentials, businesses, processing });
made.set(name, one);
if (typeof one?.handle !== 'function' || !Number.isSafeInteger(one.timeout) || one.timeout < 1)
throw new BusError('invalid-adapter');
}
if (made.size) {
adapter.timeout = Math.max(...[...made.values()].map((a) => a.timeout));
adapter.handle = async (cap, verb, args) => {
const one = made.get(trackerOf.get(broker.identity(cap).business));
if (!one) throw new BusError('tracker-unconfigured');
return one.handle(cap, verb, args);
};
}
const web = await webServices(businesses);
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
if (runRoot) sweepRunDir(runRoot);
@@ -913,7 +968,7 @@ export async function startBroker({
web: (business) => web.get(business) ?? null,
host: host ? { busy: (...args) => host.busy(...args),
run: (...args) => processing.work(() => host.run(...args)) } : null,
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
tasks: adapter?.handle ? { handle: adapter.handle, timeout: adapter.timeout } : null,
broker,
path,
peerReader,
+12 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '1cec88b0b697c648bcab18b45f86d85526aa1701a7619046367e16c38476aee8';
export const SCHEMA_VERSION = '3t';
export const SCHEMA_SHA256 = 'e5868996851ee60a3c6d2798a17b9da446ba666c859d84d1b575ca5104ae61f8';
export const SCHEMA_VERSION = '3v';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -112,6 +112,14 @@ const migrations = [{
// and its guards; no existing row changes and nothing is deleted.
from: '3s', to: '3t',
sql: readFileSync(new URL('./migrations/3s-3t.sql', import.meta.url), 'utf8'),
}, {
from: '3t', to: '3u',
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
}, {
// U4a (#1873): the task store built into each organization, the default task-tracker module.
// Only a new append-only table, its index and its guards; no existing row changes.
from: '3u', to: '3v',
sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -138,6 +146,8 @@ const timedTables = [
'request_snapshot_events',
'request_project_events',
'project_archives',
'project_system_types',
'store_tasks',
];
const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -94,6 +94,8 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -101,7 +103,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -151,7 +153,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -163,14 +165,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+3 -1
View File
@@ -35,7 +35,9 @@ const baselines = {
// Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets
// revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change.
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
"packages/webui/src/bus.mjs": "34fa64ffa17886d86243d7712fdb6f632c22b96412b642d785ddbc05fbd79692",
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+2 -2
View File
@@ -616,7 +616,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -640,7 +640,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3u';")
.replace('}];\nconst timedTables', `}, { from: '3t', to: '3u', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3w';")
.replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3t ready/);
assert.match(migrated.stdout, /bus schema 3v ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+1 -1
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
+29 -3
View File
@@ -4,7 +4,7 @@
// scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
@@ -18,7 +18,7 @@ const OTHER = 'fake_other_token_5555555555';
const DATE = '2099-01-01';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
async function setup(t) {
async function setup(t, { storeRoot = true } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
@@ -26,7 +26,7 @@ async function setup(t) {
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
@@ -161,3 +161,29 @@ test('T5: issue, rotate, revoke and delete are live, rewrite the business file a
for (const path of walk(c.root))
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
});
test('#1886: a store reference with no store root yet starts the bus, makes the root private and issues', async (t) => {
const c = await setup(t, { storeRoot: false });
const dir = join(c.root, 'secrets', 'agents');
assert.equal(statSync(dir).mode & 0o777, 0o700);
assert.equal(statSync(join(c.root, 'secrets')).mode & 0o077, 0);
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
});
test('#1886: a store root that exists with loose modes still refuses start', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-loose-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
chmodSync(join(root, 'secrets', 'agents'), 0o750);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {},
roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
};
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
});
+8 -6
View File
@@ -36,11 +36,11 @@ function source3j(t) {
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -84,6 +84,8 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -105,7 +107,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -131,7 +133,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -142,14 +144,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+8 -6
View File
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -42,7 +42,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -87,6 +87,8 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -110,7 +112,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -135,7 +137,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -146,14 +148,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as bus from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'pt-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const broker = new bus.Broker({ store, businesses: {
acme: { id: 'acme', human: 'jason', roles: {
pm: { authority: { withinRole: [], crossRole: [] } },
cto: { authority: { withinRole: [], crossRole: [] } },
}, arbiters: { technical: 'cto', delivery: 'pm' } },
} });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
return { broker, human };
}
test('person task vocabulary pins exactly the five frozen store verbs', () => {
assert.deepEqual(bus.PERSON_TASK_VERBS, [
'tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete',
]);
assert.ok(Object.isFrozen(bus.PERSON_TASK_VERBS));
});
test('every person task handler is declared in the vocabulary, even if the dispatch guard regresses', () => {
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
const handlers = [...source.matchAll(/^\s*case\s+(['"])(tasks\.[^'"]+)\1\s*:/gm)].map(match => match[2]);
assert.deepEqual(handlers.sort(), [...bus.PERSON_TASK_VERBS].sort());
});
test('every listed person task verb reaches its handler, while an unlisted verb is unknown', t => {
const { broker, human } = fixture(t);
assert.ok(Array.isArray(bus.PERSON_TASK_VERBS));
for (const verb of bus.PERSON_TASK_VERBS) {
// Missing required fields reaches the handler's field validation, not unknown-verb.
assert.throws(() => broker.request(human, { verb, args: {} }), { code: 'invalid-request' }, verb);
}
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: {} }), { code: 'unknown-verb' });
});
test('unknown person task verbs retain common argument and reader refusal precedence', t => {
const { broker, human } = fixture(t);
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: [] }), { code: 'invalid-request' });
const reader = broker.bindReader({ business: 'acme' });
assert.throws(() => broker.request(reader, { verb: 'tasks.foo' }), { code: 'read-only' });
for (const verb of ['tasks.foo', 'tasks.create']) {
assert.throws(() => broker.request(reader, { verb }), { code: 'read-only' }, verb);
}
});
+4 -1
View File
@@ -198,6 +198,9 @@ test('3p through 3s migration preserves existing request rows and the append-onl
const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
// 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", '');
const db = new DatabaseSync(path); db.exec(ddl);
const at = '2026-11-03T00:00:00.000Z';
@@ -213,7 +216,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
+5 -4
View File
@@ -143,7 +143,8 @@ const here = (p) => new URL(p, import.meta.url);
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
const ddl3s = currentDDL.replace(migrationSQL, '');
// 3u-3v (#1873, U4a) adds store_tasks after them; a 3s store has neither.
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), '');
function source3s(t) {
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
@@ -178,7 +179,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
@@ -211,7 +212,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3s';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3s';")
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -221,7 +222,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
assert.throws(() => new Store3s(root), /schema-mismatch/);
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
const { root: old } = source3s(t);
+7 -4
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
@@ -302,7 +302,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de
});
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3t');
assert.equal(SCHEMA_VERSION, '3v');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
@@ -310,8 +310,11 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
// 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
// 3t-3u (#1873, SYS1) adds project_system_types the same way, and 3u-3v (U4a) adds store_tasks.
const step3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
const step3v = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
const ddl3q = current.replace(step3t, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
const ddl3q = current.replace(step3t, '').replace(step3u, '').replace(step3v, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
@@ -328,7 +331,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
@@ -230,6 +230,9 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
// 3s-3t (#797) adds project_archives; the 3r fixture predates it and the replayed chain recreates it.
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'advanced'", ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
@@ -250,7 +253,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before);
assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards);
assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at);
assert.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
+8 -2
View File
@@ -278,6 +278,9 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
// 3s-3t (#797) adds project_archives after the request tables; the pre-3t fixtures below
// predate it and the replayed chain recreates it, so the step's text comes out here too.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
@@ -290,7 +293,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); assert.equal(SCHEMA_VERSION, '3t');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n'); assert.equal(SCHEMA_VERSION, '3v');
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
@@ -313,6 +316,9 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
// The 3o schema is the current one with the narrower kind list.
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
const db = new DatabaseSync(path); db.exec(ddl3o);
@@ -329,7 +335,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
+1 -1
View File
@@ -45,7 +45,7 @@ test('requirements returns the committed list to a holding role session, and no
const pm = agent('pm');
const list = call(b, pm, 'requirements');
assert.deepEqual(list, loadRequirements());
assert.equal(list.requirements.length, 101);
assert.equal(list.requirements.length, 118);
assert.deepEqual(call(b, agent('coder'), 'requirements'), list);
assert.throws(() => call(b, human, 'requirements'), /agent-required/);
assert.throws(() => call(b, reader, 'requirements'), /read-only/);
+3 -2
View File
@@ -527,7 +527,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
// #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
assert.equal(count('table') - 1, 31);
assert.equal(count('trigger'), 119);
// 3t to 3u adds project_system_types and four guards; U4a #1873 (3u to 3v) adds store_tasks and its seven guards.
assert.equal(count('table') - 1, 33);
assert.equal(count('trigger'), 130);
assert.equal(count('view'), 5);
});
+4 -4
View File
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
const after = tables(path);
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3m';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3m';")
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
const Store3m = await engine3m(root);
assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+109
View File
@@ -0,0 +1,109 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
test('project selections require enabled types, retain inactive selections, and only the owner writes', t => {
const root = mkdtempSync(join(tmpdir(), 'sys-'));
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
const broker = new Broker({ store, businesses: { demo: business } });
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = broker.bindTester({ business: 'demo' });
const agent = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
const call = (verb, args = {}, cap = owner) => broker.request(cap, { verb, args });
const project = call('project.create', { name: 'example' }).id;
assert.deepEqual(call('project.system-types', { project }), { project, selected: [], active: [], inactive: [] });
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['montage'] }), /system-type-not-enabled/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, tester), /tester-refused/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, agent), /human-required/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding', 'coding'] }), /invalid-request/);
call('project.system-types.set', { project, systemTypes: ['coding'] });
assert.deepEqual(call('project.system-types', { project }).active, ['coding']);
broker.reloadBusiness('demo', { ...business, plugins: { 'system-type': [] } });
assert.deepEqual(call('project.system-types', { project }), { project, selected: ['coding'], active: [], inactive: ['coding'] });
assert.deepEqual(store.all('SELECT by FROM project_system_types').map(r => r.by), ['jason']);
assert.throws(() => store.run("UPDATE project_system_types SET types='[]'"), /append-only/);
assert.throws(() => store.run('DELETE FROM project_system_types'), /append-only/);
assert.throws(() => store.run("INSERT INTO project_system_types(at,business,project,types,by) VALUES(?,?,?,?,?)", new Date().toISOString(), 'other', project, '[]', 'jason'), /belongs to its project/);
});
test('console owner can read system types but cannot write project selections', async t => {
const root = mkdtempSync(join(tmpdir(), 'sys-'));
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
let discoveries = 0, enabled = ['coding'];
const broker = new Broker({ store, businesses: { demo: business }, organization: (_id, verb) => { if (verb === 'system-types.current') return enabled; if (verb === 'system-types.installed') { discoveries++; return installedSystemTypes(); } } });
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const consoleOwner = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
const tester = broker.bindTester({ business: 'demo' });
assert.throws(() => broker.request(tester, { verb: 'plugins.set', args: { revision: 'a'.repeat(64), plugins: { 'system-type': ['coding'] } } }), /human-required/);
const project = broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
const result = await broker.request(consoleOwner, { verb: 'system-types.console', args: { project } });
assert.deepEqual(result.enabled, ['coding']);
assert.equal(result.installed.length, 5);
assert.equal(discoveries, 1);
broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } });
enabled = [];
assert.deepEqual(broker.request(owner, { verb: 'project.system-types', args: { project } }).inactive, ['coding']);
assert.throws(() => broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /system-type-not-enabled/);
assert.deepEqual(result.project, { project, selected: [], active: [], inactive: [] });
assert.throws(() => broker.request(consoleOwner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /human-required/);
});
test('3t store migrates offline without altering existing project rows', async t => {
const { DatabaseSync } = await import('node:sqlite');
const { readFileSync, mkdirSync, chmodSync } = await import('node:fs');
const { schemaDigest } = await import('../src/store.mjs');
const root = mkdtempSync(join(tmpdir(), 'sys-migration-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const dbpath = join(root, 'bus/bus.sqlite');
const db = new DatabaseSync(dbpath);
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
const step = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
assert.equal(ddl.split(step).length, 2);
// 3u-3v (U4a) adds store_tasks after this step; a 3t store has neither.
const later = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
db.exec(ddl.replace(step, '').replace(later, ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3t');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-01-01T00:00:00.000Z');
db.prepare('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)').run('old', '2026-01-01T00:00:00.000Z', 'demo', 'old', null, 'jason');
db.close(); chmodSync(dbpath, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const migrated = new Store(root, { migrateSchema: true }); t.after(() => migrated.close());
assert.equal(migrated.get('SELECT name FROM projects WHERE id=?', 'old').name, 'old');
assert.equal(migrated.get('SELECT count(*) AS n FROM project_system_types').n, 0);
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
});
test('runtime reads the current organization file for project selections and console discovery', async t => {
const { mkdirSync } = await import('node:fs');
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'sys-runtime-'));
const rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'system-type': ['coding'] };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const consoleOwner = bus.broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
const call = (verb, args = {}, cap = human) => bus.broker.request(cap, { verb, args });
const project = call('project.create', { name: 'example' }).id;
call('project.system-types.set', { project, systemTypes: ['coding'] });
const view = await call('system-types.console', { project }, consoleOwner);
assert.deepEqual(view.enabled, ['coding']);
assert.deepEqual(view.project.active, ['coding']);
assert.equal(view.installed.length, 5);
const plugins = call('plugins');
call('plugins.set', { revision: plugins.revision, plugins: { 'system-type': [] } });
assert.deepEqual(call('project.system-types', { project }).inactive, ['coding']);
});
+548
View File
@@ -0,0 +1,548 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, STORE_FIELDS, STORE_POSITION_STEP, STORE_VERB_FIELDS, storeNewTask } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Client } from '../src/client.mjs';
import { storeAdapter } from '../../tasks/src/store.mjs';
import { loadFields } from '../../tasks/src/fields.mjs';
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1, REQ-WS-8): the task store built into each organization, the
// default task-tracker module. Roles write through the eight task verbs (packages/tasks/src/store.mjs
// over broker.storeTask), the person through tasks.create/edit/archive/unarchive/delete, and every
// read is the bus's `tasks` verb, the same shape whichever module holds the tasks.
const AUTHORITY = {
pm: {
withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'role.launch', 'message.send'],
crossRole: ['task.priority.change', 'task.scope.change'],
},
coder: { withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], crossRole: ['task.scope.change'] },
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
cto: { withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'], crossRole: ['task.scope.change'] },
};
const roles = () => Object.fromEntries(Object.entries(AUTHORITY).map(([r, authority]) => [r, { definition: r, authority: structuredClone(authority) }]));
const business = (id, extra = {}) => ({ id, human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: roles(), ...extra });
const DIGEST = /^[0-9a-f]{64}$/;
// One bus (one data root) holding the given organizations, with the store module over it.
async function org(t, ids = ['acme'], { taskTracker = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'ts-'));
const businesses = Object.fromEntries(ids.map((id) => [id, business(id)]));
const store = new Store(root);
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
const adapter = await storeAdapter()({ broker, businesses });
let removed = false;
t.after(() => {
adapter.close();
if (!removed) {
store.close();
rmSync(root, { recursive: true, force: true });
}
});
const caps = new Map(), humans = new Map();
const agent = (role, id = ids[0]) => {
const key = id + '/' + role;
if (!caps.has(key)) {
const cap = broker.bindLaunch({ business: id, role, run: role + '-run', harness: 'pi' });
broker.request(cap, { verb: 'role.claim' });
caps.set(key, cap);
}
return caps.get(key);
};
const human = (id = ids[0]) => {
if (!humans.has(id)) humans.set(id, broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true }));
return humans.get(id);
};
const instruction = (id = ids[0]) => broker.request(human(id), { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
const call = (role, verb, args, id = ids[0]) => broker.requestTask(agent(role, id), { verb, args }, adapter.handle);
const person = (verb, args, id = ids[0]) => broker.request(human(id), { verb, args });
const read = (args = {}, id = ids[0]) => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args });
const create = (title, extra = {}, id = ids[0]) => call('pm', 'task.create', { title, request: instruction(id), requirement: 'REQ-TASK-1', ...extra }, id);
// A cross-role verb's approval: the role raises it in the technical domain and the cto resolves it.
const approve = (role, action, target, id = ids[0]) => {
const d = broker.request(agent(role, id), {
verb: 'decision.raise',
args: { action, domain: 'technical', target, question: 'Allow?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false },
});
broker.request(agent('cto', id), { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
return d.id;
};
const remove = () => {
removed = true;
store.close();
rmSync(root, { recursive: true, force: true });
};
return { root, store, broker, agent, human, instruction, call, person, read, create, approve, remove };
}
const codeOf = async (p) => {
try {
await p;
} catch (e) {
return e.code;
}
return null;
};
const syncCode = (fn) => {
try {
fn();
} catch (e) {
return e.code;
}
return null;
};
test('REQ-TASK-1 the store keeps the slice 1 field table: one field list per role verb, all of them store fields', () => {
assert.deepEqual(Object.keys(STORE_VERB_FIELDS).sort(), Object.keys(loadFields().verbs).sort());
for (const [verb, list] of Object.entries(STORE_VERB_FIELDS))
for (const k of list) assert.ok(STORE_FIELDS.includes(k), `${verb} names ${k}`);
});
test('REQ-TASK-1 every role verb writes the store through the module', async (t) => {
const f = await org(t);
const request = f.instruction();
const made = await f.call('pm', 'task.create', { title: 'Write the store', description: 'd', priority: 2, labels: [3, 1], group: 'build', request, requirement: 'REQ-TASK-1' });
assert.equal(made.task_ref, 'task:1');
assert.match(made.digest, DIGEST);
let row = f.read({ task_ref: 'task:1' });
assert.equal(row.source, 'store');
assert.equal(row.tracker, 'store');
assert.equal(row.role, 'pm');
assert.equal(row.verb, 'task.create');
assert.equal(row.revision, 1);
assert.equal(row.digest, made.digest);
assert.equal(row.archived, false);
assert.deepEqual(row.fields, { ...storeNewTask({ title: 'Write the store', request, requirement: 'REQ-TASK-1' }), description: 'd', priority: 2, labels: [1, 3], group: 'build', position: 0 });
// The row's event is the action.allowed the write recorded, on the task's subject.
const event = f.store.get('SELECT kind, subject, body FROM events WHERE id=?', row.event);
assert.equal(event.kind, 'action.allowed');
assert.equal(event.subject, 'task:1');
assert.equal(JSON.parse(event.body).action, 'task.create');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'reviewer' })), 'task-assigned');
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'reviewer' });
assert.deepEqual(f.read({ task_ref: 'task:1' }).fields.assignees, ['reviewer']);
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'coder' });
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'nobody' })), 'unknown-role');
await f.call('pm', 'task.schedule', { task_ref: 'task:1', due_date: '2026-12-01T00:00:00.000Z', labels: { add: [5], remove: [1] } });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.fields.due_date, '2026-12-01T00:00:00.000Z');
assert.deepEqual(row.fields.labels, [3, 5]);
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1' })), 'invalid-request');
await f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.5, state: 'in-progress', comment: 'started' });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.role, 'coder');
assert.equal(row.fields.state, 'in-progress');
assert.equal(row.fields.bucket, 'in-progress');
assert.equal(row.fields.percent_done, 0.5);
assert.equal(row.fields.comments, 1);
assert.deepEqual(row.fields.comment, { text: 'started', by: 'coder' });
// done is task.close's, never the assignee's.
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', state: 'done' })), 'invalid-state');
// Cross-role verbs need a resolved decision, as they do on Vikunja, and spend it once.
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4 })), 'decision-required');
const d = f.approve('pm', 'task.priority.change', 'task:1');
await f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4, decision: d });
assert.equal(f.read({ task_ref: 'task:1' }).priority, 4);
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 5, decision: d })), 'decision-consumed');
const scope = f.approve('coder', 'task.scope.change', 'task:1');
await f.call('coder', 'task.scope.change', { task_ref: 'task:1', title: 'Write the store well', decision: scope });
assert.equal(f.read({ task_ref: 'task:1' }).fields.title, 'Write the store well');
const closed = await f.call('pm', 'task.close', { task_ref: 'task:1', verdict: 'review-1' });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.digest, closed.digest);
assert.equal(row.fields.done, true);
assert.equal(row.fields.state, 'done');
assert.equal(row.fields.verdict, 'review-1');
// A done task takes no role write; reopening it is the person's.
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', labels: { add: [9] } })), 'task-done');
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 1 })), 'task-done');
// Nine writes; no refusal wrote a row.
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 9);
assert.deepEqual(f.store.all('SELECT revision FROM store_tasks ORDER BY seq').map((r) => r.revision), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test('REQ-TASK-1 field writers: a role cannot write a field it does not own', async (t) => {
const f = await org(t);
await f.create('Owned fields');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
// The module holds the pm verbs to the pm definition.
assert.equal(await codeOf(f.call('coder', 'task.create', { title: 'x', request: f.instruction(), requirement: 'REQ-TASK-1' })), 'field-writer');
assert.equal(await codeOf(f.call('coder', 'task.assign', { task_ref: 'task:1', role: 'coder' })), 'field-writer');
assert.equal(await codeOf(f.call('coder', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'field-writer');
assert.equal(await codeOf(f.call('reviewer', 'task.close', { task_ref: 'task:1', verdict: 'v' })), 'field-writer');
// task.update.assigned needs the role among the assignees.
assert.equal(await codeOf(f.call('reviewer', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.1 })), 'not-assigned');
// The broker holds every write to its verb's fields, whatever the module asks for.
const pm = f.agent('pm'), coder = f.agent('coder');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.schedule', task_ref: 'task:1', change: (x) => ({ ...x, title: 'sneaky' }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(coder, { verb: 'task.update.assigned', task_ref: 'task:1', change: (x) => ({ ...x, priority: 5 }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.close', task_ref: 'task:1', verdict: 'v', change: (x) => ({ ...x, done: true, state: 'done', bucket: 'done', assignees: [] }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.assign', task_ref: 'task:1', change: (x) => ({ ...x, extra: 1 }) })), 'field-writer');
// A person's session is not a role here.
assert.equal(syncCode(() => f.broker.storeTask(f.human(), { verb: 'task.schedule', task_ref: 'task:1', change: (x) => x })), 'agent-required');
// Vikunja-only arguments refuse; nothing was written by any refusal.
for (const extra of [{ project: 1 }, { relations: [] }, { project_key: 'k' }, { requirement_snapshot: 's' }])
assert.equal(await codeOf(f.create('vikunja args', extra)), 'invalid-request', JSON.stringify(extra));
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 2);
// A refusal on a store task is filed on its trail.
const trail = f.person('trail', { subject: 'task:1' });
assert.ok(trail.some((r) => r.table === 'events' && r.kind === 'action.refused' && r.body.code === 'not-assigned'));
});
test('REQ-TASK-1 a person creates, edits, archives, unarchives and deletes; each write is a human.input event', async (t) => {
const f = await org(t);
const made = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Plan', group: 'todo', labels: [2] });
assert.equal(made.task_ref, 'task:1');
assert.equal(made.human, 'jason');
assert.equal(made.role, null);
assert.equal(made.fields.position, 0);
assert.equal(made.fields.request, null);
const event = f.store.get('SELECT kind, subject, actor_role, body FROM events WHERE id=?', made.event);
assert.equal(event.kind, 'human.input');
assert.equal(event.subject, 'task:1');
assert.equal(event.actor_role, null);
assert.deepEqual(JSON.parse(event.body), { kind: 'task', task_ref: 'task:1', tracker: 'store', op: 'create', verb: 'tasks.create' });
assert.equal(syncCode(() => f.person('tasks.create', {})), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x', request: 'r' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', state: 'shipped' })), 'invalid-state');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', assignees: ['nobody'] })), 'unknown-role');
// expect is the digest the person last read; a later write refuses.
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', expect: 'a'.repeat(64), title: 'x' })), 'task-conflict');
const done = f.person('tasks.edit', { task_ref: 'task:1', expect: made.digest, state: 'done', assignees: ['coder'] });
assert.equal(done.fields.done, true);
assert.equal(done.fields.bucket, 'done');
// The person may reopen a done task.
assert.equal(f.person('tasks.edit', { task_ref: 'task:1', state: 'todo' }).fields.done, false);
// Agents and the reader don't reach the person's verbs.
assert.equal(syncCode(() => f.broker.request(f.agent('pm'), { verb: 'tasks.create', args: { title: 'x' } })), 'human-required');
assert.notEqual(syncCode(() => f.broker.request(f.broker.bindReader({ business: 'acme' }), { verb: 'tasks.create', args: { title: 'x' } })), null);
const other = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Kept' });
f.person('tasks.archive', { task_ref: 'task:1' });
assert.equal(syncCode(() => f.person('tasks.archive', { task_ref: 'task:1' })), 'task-already-archived');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'x' })), 'task-archived');
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'task-archived');
// An archived task leaves the board read and the filtered read, unless archived: true asks.
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
assert.deepEqual(f.read({ limit: 10 }).tasks.map((r) => r.task_ref), [other.task_ref]);
const archived = f.read({ archived: true });
assert.deepEqual(archived.tasks.map((r) => [r.task_ref, r.archived]), [['task:1', true]]);
assert.equal(archived.total, 1);
assert.equal(f.read({ task_ref: 'task:1' }).archived, true);
f.person('tasks.unarchive', { task_ref: 'task:1' });
assert.equal(syncCode(() => f.person('tasks.unarchive', { task_ref: 'task:1' })), 'task-not-archived');
assert.equal(f.read({ task_ref: 'task:1' }).archived, false);
const gone = f.person('tasks.delete', { task_ref: 'task:1' });
assert.equal(gone.task_ref, 'task:1');
assert.equal(gone.deleted, true);
assert.equal(syncCode(() => f.read({ task_ref: 'task:1' })), 'task-not-found');
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'back' })), 'task-not-found');
assert.equal(syncCode(() => f.person('tasks.delete', { task_ref: 'task:1' })), 'task-not-found');
// The history stays in the organization's bus until the organization is removed.
const trail = f.person('trail', { subject: 'task:1' }).filter((r) => r.table === 'store_tasks');
assert.deepEqual(trail.map((r) => r.op), ['create', 'change', 'change', 'archive', 'unarchive', 'delete']);
assert.equal(trail[0].fields.title, 'Plan');
// Numbers are never reused.
assert.equal(f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Next' }).task_ref, 'task:3');
});
test('REQ-TASK-1 a role create cites the instruction; the trail carries it, and an op answers the same task again', async (t) => {
const f = await org(t);
const request = f.instruction();
const args = { title: 'Once', request, requirement: 'REQ-TASK-1', op: 'op-1' };
const first = await f.call('pm', 'task.create', args);
const again = await f.call('pm', 'task.create', args);
assert.deepEqual(again, first);
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 1);
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, title: 'Other' })), 'task-create-conflict');
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, op: undefined, request: 'not-an-instruction' })), 'request-not-found');
assert.equal(await codeOf(f.call('pm', 'task.create', { title: 'x', request, requirement: 'nope' })), 'invalid-request');
const trail = f.person('trail', { subject: 'task:1' });
assert.ok(trail.some((r) => r.table === 'events' && r.id === request && r.kind === 'human.input'));
assert.ok(trail.some((r) => r.table === 'messages'));
// A stale expect refuses a role write too.
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: 'a'.repeat(64) })), 'task-conflict');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: first.digest });
});
test('REQ-TASK-1 ordering and grouping: positions step by 1024 inside a group, moves go to the end, reads in board order', async (t) => {
const f = await org(t);
for (const title of ['A', 'B', 'C']) await f.create(title);
await f.create('D', { group: 'review' });
await f.create('E', { group: 'build' });
const at = () => f.read().map((r) => [r.fields.title, r.fields.group, r.fields.position]);
assert.deepEqual(at(), [['A', null, 0], ['B', null, STORE_POSITION_STEP], ['C', null, 2 * STORE_POSITION_STEP], ['E', 'build', 0], ['D', 'review', 0]]);
// A move to another group with no position goes to its end; a position alone reorders in place.
await f.call('pm', 'task.schedule', { task_ref: 'task:2', group: 'review' });
await f.call('pm', 'task.schedule', { task_ref: 'task:3', position: 10 });
f.person('tasks.edit', { task_ref: 'task:1', position: 5000 });
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', 'build', 0], ['D', 'review', 0], ['B', 'review', STORE_POSITION_STEP]]);
// A person's move to a group with no position also goes to its end (after E, still in build then); ungrouping too.
f.person('tasks.edit', { task_ref: 'task:4', group: 'build' });
f.person('tasks.edit', { task_ref: 'task:5', group: null });
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', null, 6024], ['D', 'build', STORE_POSITION_STEP], ['B', 'review', STORE_POSITION_STEP]]);
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', group: ' ' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', position: -1 })), 'invalid-request');
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', position: 1.5 })), 'invalid-request');
// The filtered short form pages in board order with the store's group, position and archived.
const one = f.read({ limit: 2 });
assert.deepEqual(one.tasks.map((r) => r.task_ref), ['task:3', 'task:1']);
assert.equal(one.total, 5);
assert.equal(one.next, 'task:1');
assert.deepEqual(Object.keys(one.tasks[0]).sort(), ['archived', 'assignees', 'bucket', 'changed_at', 'done', 'group', 'labels', 'position', 'priority', 'requirement', 'task_ref', 'title']);
const two = f.read({ limit: 2, after: one.next });
assert.deepEqual(two.tasks.map((r) => r.task_ref), ['task:5', 'task:4']);
const three = f.read({ limit: 2, after: two.next });
assert.deepEqual(three.tasks.map((r) => r.task_ref), ['task:2']);
assert.equal(three.next, null);
assert.equal(syncCode(() => f.read({ after: 'task:99' })), 'task-not-found');
// The other filters apply to store tasks as to Vikunja's.
await f.call('pm', 'task.assign', { task_ref: 'task:4', role: 'coder' });
assert.deepEqual(f.read({ assignee: 'coder' }).tasks.map((r) => r.task_ref), ['task:4']);
assert.deepEqual(f.read({ requirement: 'REQ-TASK-1', done: false }).total, 5);
assert.deepEqual(f.read({ project_key: 'k'.repeat(8) }).tasks, []);
});
test('REQ-TASK-1 two organizations keep their own tasks, and removing one removes only its tasks', async (t) => {
// Jason 11-07: each organization has its own bus and data root.
const a = await org(t, ['acme']), b = await org(t, ['beta']);
await a.create('acme task');
await b.create('beta task');
b.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'beta by hand' });
assert.deepEqual(a.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'acme task']]);
assert.deepEqual(b.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'beta task'], ['task:2', 'beta by hand']]);
// Removing an organization removes its data root, and its tasks with it.
a.remove();
assert.equal(existsSync(a.root), false);
assert.deepEqual(b.read().map((r) => r.fields.title), ['beta task', 'beta by hand']);
await b.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
// Within one bus, too, a business reads and writes only its own rows.
const both = await org(t, ['one', 'two']);
await both.create('one task', {}, 'one');
assert.deepEqual(both.read({}, 'two'), []);
assert.equal(syncCode(() => both.read({ task_ref: 'task:1' }, 'two')), 'task-not-found');
assert.equal(await codeOf(both.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }, 'two')), 'task-not-found');
assert.equal(syncCode(() => both.person('tasks.archive', { task_ref: 'task:1' }, 'two')), 'task-not-found');
assert.equal((await both.create('two task', {}, 'two')).task_ref, 'task:1');
assert.deepEqual(both.read({}, 'one').map((r) => r.fields.title), ['one task']);
});
test('REQ-PLUG-1 a business on another tracker module refuses the store verbs', async (t) => {
const f = await org(t, ['acme'], { taskTracker: () => 'vikunja' });
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x' })), 'tracker-not-store');
assert.equal(await codeOf(f.create('x')), 'tracker-not-store');
assert.deepEqual(f.read(), []);
assert.throws(() => new Broker({ store: f.store, businesses: { acme: business('acme') }, taskTracker: 'store' }), TypeError);
});
test('REQ-TASK-1 store_tasks guards: append-only, revisions in order, one create, nothing after a delete, archive changes state', async (t) => {
const f = await org(t);
await f.create('Guarded');
const event = f.store.get('SELECT event FROM store_tasks').event;
const fields = f.store.get('SELECT fields FROM store_tasks').fields;
const insert = (task, revision, op, by = ['pm', 'pm-run', null]) =>
f.store.run('INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
'2026-11-09T00:00:00.000Z', 'acme', task, revision, op, 'x', ...by, event, fields);
for (const [sql, ...args] of [["UPDATE store_tasks SET verb='y'"], ['DELETE FROM store_tasks'],
["INSERT OR REPLACE INTO store_tasks(seq,at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(1,'t','acme',1,2,'change','x','pm','pm-run',NULL,?,?)", event, fields]])
assert.throws(() => f.store.run(sql, ...args), /store_tasks is append-only/, sql);
assert.throws(() => insert(1, 3, 'change'), /a store task revision follows the last one/);
assert.throws(() => insert(1, 2, 'create'), /a store task is created once/);
assert.throws(() => insert(3, 1, 'create'), /with the next number/);
assert.throws(() => insert(1, 2, 'unarchive'), /a store task archive row changes state/);
assert.throws(() => insert(1, 2, 'change', ['pm', null, null]), /CHECK/);
assert.throws(() => insert(1, 2, 'change', ['pm', 'pm-run', 'jason']), /CHECK/);
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,?,'[]')", event), /CHECK/);
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,'no-such-event',?)", fields), /FOREIGN KEY/);
insert(1, 2, 'archive', [null, null, 'jason']);
assert.throws(() => insert(1, 3, 'archive', [null, null, 'jason']), /a store task archive row changes state/);
insert(1, 3, 'delete', [null, null, 'jason']);
assert.throws(() => insert(1, 4, 'change'), /a deleted store task takes no more rows/);
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 3);
});
// --- The 3u→3v migration: store_tasks is new and empty, and everything else stays. ---
const here = (p) => new URL(p, import.meta.url);
const stepSQL = readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
const master = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
} finally {
db.close();
}
};
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value;
} finally {
db.close();
}
};
test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every row stays, and the schema is the fresh one', (t) => {
assert.equal(currentDDL.split(stepSQL).length, 2, 'schema.sql holds the 3u→3v step exactly once');
assert.ok(currentDDL.endsWith(stepSQL), 'the 3u→3v step is the last in schema.sql');
const root = mkdtempSync(join(tmpdir(), 'ts-3u-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
db.exec(currentDDL.replace(stepSQL, ''));
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u');
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z');
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-09T00:00:00.000Z','acme','action.allowed','{}')").run();
db.close();
chmodSync(path, 0o600);
const was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3v');
assert.equal(store.get('SELECT id FROM events').id, 'preserved');
assert.deepEqual(store.all('SELECT * FROM store_tasks'), []);
const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
});
// --- The bus runtime: the store is the default module; Vikunja stays the host's. ---
test('REQ-PLUG-1 the runtime gives a business with no tracker the store and routes a Vikunja business to the host adapter', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'ts-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const businesses = {
acme: business('acme', { plugins: { harness: ['pi'] } }),
beta: business('beta', { plugins: { harness: ['pi'], 'task-tracker': 'vikunja' } }),
};
const seen = [];
let closed = 0;
const launch = (id) => ({ business: id, role: 'pm', run: id + '-pm', harness: 'pi', pid: process.pid, startTime: '1' });
const runtime = await startBroker({
dataRoot: root,
businesses,
launches: [launch('acme'), launch('beta')],
trackers: { beta: { baseUrl: 'http://127.0.0.1:9', project: 1 } },
tasks: async () => ({ handle: async (cap, verb) => (seen.push(verb), { routed: 'vikunja' }), timeout: 1000, close: async () => closed++ }),
});
try {
const [acme, beta] = runtime.launches.map((l) => new Client({ path: runtime.path, cap: l.cap }));
await acme.call('role.claim');
await beta.call('role.claim');
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
const made = await acme.call('task.create', { title: 'From the socket', request, requirement: 'REQ-TASK-1' });
assert.equal(made.task_ref, 'task:1');
assert.deepEqual(await beta.call('task.create', { title: 'x', request: 'r', requirement: 'REQ-TASK-1' }), { routed: 'vikunja' });
assert.deepEqual(seen, ['task.create']);
// The Console's read: the `tasks` verb on the organization's reader cap, the same rows either way.
const rows = runtime.broker.request(runtime.broker.bindReader({ business: 'acme' }), { verb: 'tasks' });
assert.deepEqual(rows.map((r) => [r.task_ref, r.source, r.fields.title]), [['task:1', 'store', 'From the socket']]);
assert.equal(syncCode(() => runtime.broker.request(runtime.broker.bindHuman({ business: 'beta', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'tasks.create', args: { title: 'x' } })), 'tracker-not-store');
} finally {
await runtime.close();
}
assert.equal(closed, 1);
// A business that names the store while the boot config also gives it a Vikunja tracker refuses the boot.
await assert.rejects(
startBroker({ dataRoot: root, businesses: { acme: business('acme', { plugins: { 'task-tracker': 'store' } }) }, trackers: { acme: { baseUrl: 'http://127.0.0.1:9', project: 1 } } }),
(e) => e.code === 'tracker-config',
);
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
});
test('review: an unknown task-tracker module must refuse boot', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'unknown-'));
t.after(() => rmSync(root, {recursive:true, force:true}));
let runtime;
try {
await assert.rejects(async () => {
runtime = await startBroker({dataRoot:root, businesses:{acme:business('acme', {plugins:{harness:['pi'], 'task-tracker':'no-such-tracker'}})}});
}, (e) => e.code === 'tracker-config');
} finally { await runtime?.close(); }
// Startup cleanup releases the same store so a corrected configuration can start.
const corrected = await startBroker({dataRoot:root, businesses:{acme:business('acme')}});
await corrected.close();
});
test('review: a stale expect must reject a delete after an intervening archive', async (t) => {
const f=await org(t);
const old=f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Read before archiving'});
const archived=f.person('tasks.archive', {task_ref:old.task_ref});
assert.equal(archived.revision, old.revision+1);
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:old.digest})), 'task-conflict');
const unarchived = f.person('tasks.unarchive', {task_ref:old.task_ref});
assert.equal(syncCode(()=>f.person('tasks.edit', {task_ref:old.task_ref, title:'stale', expect:old.digest})), 'task-conflict');
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:archived.digest})), 'task-conflict');
assert.equal(f.person('tasks.edit', {task_ref:old.task_ref, title:'current', expect:unarchived.digest}).fields.title, 'current');
});
test('review: human tasks can cite the PRD-required requirement id', async (t) => {
const f=await org(t);
const made=f.person('tasks.create', { title:'Traceable',requirement:'REQ-TASK-1'});
assert.equal(made.fields.requirement,'REQ-TASK-1');
assert.equal(syncCode(() => f.person('tasks.create', {title:'Missing'})), 'invalid-request');
for (const requirement of [null, '', 'REQ-foo-1', 'REQ-TASK-0']) {
assert.equal(syncCode(() => f.person('tasks.create', {title:'Invalid', requirement})), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:made.task_ref, requirement})), 'invalid-request');
}
const edited = f.person('tasks.edit', {task_ref:made.task_ref, requirement:'REQ-TASK-2'});
assert.equal(edited.fields.requirement, 'REQ-TASK-2');
assert.equal(f.store.get('SELECT kind FROM events WHERE id=?', edited.event).kind, 'human.input');
});
test('review: a newly hired assignee can use the store after the business reloads', async (t) => {
const f=await org(t);
const made=await f.create('For a new coder');
const next=business('acme');
next.roles.newcoder=structuredClone(next.roles.coder);
f.broker.reloadBusiness('acme',next);
f.person('tasks.edit',{task_ref:made.task_ref,assignees:['newcoder']});
await f.call('newcoder','task.update.assigned',{task_ref:made.task_ref,state:'in-progress'});
assert.equal(f.read({task_ref:made.task_ref}).fields.state,'in-progress');
});
test('review: deleting an assignee does not prevent archiving the existing task', async (t) => {
const f=await org(t);
const made=f.person('tasks.create',{requirement:'REQ-TASK-1',title:'Assigned history',assignees:['coder']});
const next=business('acme'); delete next.roles.coder; next.retired=['coder'];
f.broker.reloadBusiness('acme',next);
const edited=f.person('tasks.edit',{task_ref:made.task_ref,title:'Edited',assignees:['coder']});
assert.deepEqual(edited.fields.assignees,['coder']);
assert.equal(syncCode(() => f.person('tasks.edit',{task_ref:made.task_ref,assignees:['coder','missing']})), 'unknown-role');
const archived=f.person('tasks.archive',{task_ref:made.task_ref});
assert.equal(archived.archived,true);
assert.deepEqual(archived.fields.assignees,['coder']);
f.person('tasks.delete',{task_ref:made.task_ref});
const last=JSON.parse(f.store.get('SELECT fields FROM store_tasks ORDER BY seq DESC LIMIT 1').fields);
assert.deepEqual(last.assignees,['coder']);
});
test('review: saturated and adjacent positions refuse without a write', async (t) => {
const f = await org(t);
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Moved', group:'other'});
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Tail', group:'target', position:1_000_000_000});
const before = f.store.get('SELECT count(*) n FROM store_tasks').n;
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'target'})), 'task-position-full');
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before);
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'A', group:'adjacent', position:10});
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'B', group:'adjacent', position:11});
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
});
+5 -2
View File
@@ -301,12 +301,15 @@ test('the process loads the S3 adapter from plain-data trackers', async (t) => {
const bad = await boot(t, { dataRoot: root, businesses, launches, trackers: { nope: { baseUrl: 'http://127.0.0.1:9', project: 1 } } });
assert.deepEqual(bad.reply, { ok: false, error: 'tracker-config' });
await once(bad.child, 'exit');
// An empty tracker map loads the adapter; a business without an entry refuses task verbs.
// An empty tracker map loads the adapter. U4a (#1873): a business without an entry gets the
// built-in store, not Vikunja. These fixture roles carry no definition, so the store refuses the
// caller (tracker-role) where the Vikunja adapter refused tracker-unconfigured; task-store.test.mjs
// runs the store's verbs.
const { child, reply } = await boot(t, { dataRoot: root, businesses, launches, trackers: {} });
assert.equal(reply.ok, true);
const pm = new Client({ path: reply.path, cap: reply.launches[0].cap });
await pm.call('role.claim');
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-unconfigured/);
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-role/);
const exit = once(child, 'exit');
child.send({ op: 'close' });
assert.equal((await exit)[0], 0);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3t ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3v ready', ''].join('\n'));
});
+4 -4
View File
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3l';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
export * from "../../organization/src/accounts.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/agent-levels.mjs (rename design r2, stage B).
export * from "../../organization/src/agent-levels.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/org-rights.mjs (rename design r2, stage B).
export * from "../../organization/src/org-rights.mjs";
+38
View File
@@ -1289,3 +1289,41 @@ first attempt. Completed moves are not rolled back. A held lifecycle lock can
mean a bus start is still in progress; if a process crashed and left its lock,
inspect it before removing it and resuming. Moves use rename and therefore
require the source and deleted folder to be on the same filesystem.
System types are installed plug-ins. `mosaic org system-types installed` prints
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
ids. The owner changes the enabled list through the existing organization file
update or `mosaic plugins set`. `mosaic project system-types PROJECT --org ID`
shows selected, active and inactive ids. The owner replaces the selection with
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
empty `--types ''` clears it. Disabled selections stay recorded but become
inactive. Project settings changes are append-only and require human proof.
## Accounts
`mosaic accounts` administers the instance-level accounts store locally, as the
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
and takes no business selector or bus connection. It has no organization rights
verbs. Console sign-in, sessions and membership evaluation are later Phase M
slices; these commands alone do not turn accounts mode on.
```sh
mosaic accounts init --name Operator
mosaic accounts list --json
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
mosaic accounts user disable u-0123456789ab --revision 2
mosaic accounts user enable u-0123456789ab --revision 3
mosaic accounts user signout u-0123456789ab --revision 4
mosaic accounts user remove u-0123456789ab --revision 5
```
Init creates one operator account containing every configured `console.sso`
subject and refuses if accounts.json already exists. Add mints its id and defaults
to instance role `user`; `--role operator` creates another operator. Writes require
the file revision from list. Disable, enable and signout advance `authGen`, and
the last active operator cannot be disabled or removed. Removal scans ownership
while holding the registry then accounts locks, refuses if the account owns an
organization (including archived organizations) or any registry entry lacks its
owner metadata during creation, and removes its memberships in
the same write. List reports rollback rows needing repair; its JSON omits invite
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
+2 -1
View File
@@ -74,7 +74,8 @@ export function readAccounting({ dataRoot,business,start,end }) {
const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true});
try {
db.exec('BEGIN');
if (!['3s','3t'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
// U4a (#1873) 3u-3v adds only store_tasks, which accounting never reads, so 3v joins the closed list.
if (!['3s','3t','3u','3v'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
const store = { all:(sql,...args) => db.prepare(sql).all(...args) };
const records = turnRecords(store,business);
const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business);
+1 -1
View File
@@ -79,7 +79,7 @@ test('accounting reads a real store at the current bus schema version',t=>{
const records=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});
assert.equal(records.length,1);assert.equal(records[0].input,1);
});
for(const version of ['3s','3r','3u',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
for(const version of ['3s','3r','3v',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
const f=setup(t,100);
// Only this disposable fixture is altered; the production meta table is append-only.
f.store.run('DROP TRIGGER meta_no_update');f.store.run('DROP TRIGGER meta_no_delete');
+109
View File
@@ -0,0 +1,109 @@
// Phase M2: the local, instance-uid account administration exception. No org
// rights commands and no bus transport: account init must work before orgs exist.
import { basename, join } from 'node:path';
import { configDir } from '../../organization/src/organization-config.mjs';
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ACCOUNTS_USAGE = [
'mosaic accounts init [--name NAME]',
'mosaic accounts list [--json]',
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
'mosaic accounts user disable|enable|signout|remove ID --revision N',
].join('\n');
function parse(args) {
const words = [], values = {}, flags = new Set();
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('--')) { words.push(arg); continue; }
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
if (arg === '--json') flags.add(arg);
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
else throw new CliError(ACCOUNTS_USAGE, 4);
}
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
const revision = verb === 'init' ? 0 : Number(values['--revision']);
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
return { verb, values, flags, revision, id: words[2] };
}
function owns(user, owner, providers) {
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
}
function readOwner(root) {
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
});
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
return owner;
}
export async function accountsCommand(args, io, deps) {
refuseInsideAgent(io.env);
try {
const parsed = parse(args);
const dir = deps.configDir ?? configDir(io.env);
// This is deliberately before withRegistry as well as withAccounts.
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
const providers = deps.system.console?.sso?.providers ?? [];
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
if (parsed.verb === 'list') {
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
const repairs = accountsRepairs(doc);
// Invites are not administered here. Never expose bindings or token hashes.
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
return;
}
let added;
const mutate = doc => {
if (parsed.verb === 'init') {
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
if (parsed.verb === 'user.add') {
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
const u = doc.users.find(u => u.id === parsed.id);
if (!u) throw new CliError('refused: account not found', 2);
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
else if (parsed.verb === 'user.signout') u.authGen++;
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
};
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
let result;
if (parsed.verb === 'user.remove') {
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
const user = doc.users.find(u => u.id === parsed.id);
if (!user) throw new CliError('refused: account not found', 2);
// Even disabled accounts keep ownership. A real create reserves before
// writing owner.json, so any missing owner must block removal too.
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
let owner;
try { owner = readOwner(entry.root); }
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
}
mutate(doc);
}), { write: false });
} else result = await withAccounts(ctx, opts, mutate);
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
} catch (e) {
if (e instanceof CliError) throw e;
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
if (e.committed) error.committed = true;
throw error;
}
}
+27 -4
View File
@@ -24,6 +24,7 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
import { accessCommand, accessContextFor } from "./access.mjs";
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError, CliExit } from "./errors.mjs";
@@ -43,7 +44,7 @@ import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../organization/src/org-registry.mjs";
import { orgSelector } from "./org-selector.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs";
@@ -52,6 +53,7 @@ import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
export const USAGE = `usage:
${ACCOUNTS_USAGE}
${ORG_USAGE}
${USAGE_USAGE}
${LIMITS_USAGE}
@@ -91,6 +93,7 @@ export const USAGE = `usage:
mosaic bus status <id> [--json]
mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
mosaic console stop|status
mosaic console restart
${DEV_USAGE}
${RUN_USAGE}`;
@@ -571,6 +574,14 @@ async function consoleCommand(rest, io, deps) {
io.stdout.write(`${JSON.stringify(consoleStatus(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
// U1 (#1873): the F3 Settings control from a terminal. The running Console applies its own owner
// and trail checks (org-console.mjs consoleRestart); an agent run is refused before any read.
if (sub === "restart") {
if (args.length) throw usage("console restart takes no arguments");
refuseInsideAgent(io.env);
io.stdout.write(`${JSON.stringify(await consoleRestart(organizationContext(io, deps), deps.consoleRequest ?? {}))}\n`);
return;
}
if (sub !== "start") throw usage();
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port", "--dev-insecure-bind", "--dev-allow-host"] });
if (parsed.positional.length) throw usage("console start takes no positional arguments");
@@ -583,7 +594,13 @@ async function consoleCommand(rest, io, deps) {
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
const code = await receipt.done;
// F3: this process supervises the Console. A stop signal (the unit's SIGTERM, Ctrl-C) goes to
// the Console and ends supervision cleanly; a restart the Console asks for is relaunched here.
const stop = () => receipt.stop();
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, stop);
let code;
try { code = await receipt.done; }
finally { for (const signal of ["SIGINT", "SIGTERM"]) process.off(signal, stop); }
if (code !== 0) throw new CliError("console stopped unexpectedly", code);
}
}
@@ -633,7 +650,12 @@ async function bus(rest, io, deps) {
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
const launchEnv = instance ? fixtureLaunchEnv(orgDoc, io.env) : io.env;
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
// U4a (#1873): agents get the task tools when the bus serves task verbs for the organization: a
// Vikunja tracker in the boot config, or the built-in store, which an organization with no
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
await launcher.listen();
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
@@ -712,7 +734,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "project") return ["create", "list", "archive", "unarchive", "system-types", "system-types-set"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
@@ -725,6 +747,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
now: deps.now,
});
}
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
+3
View File
@@ -83,6 +83,9 @@ export function trailSummary(r) {
return `${r.op} ${r.role} by ${r.holder_run}`;
case "task_snapshots":
return `snapshot ${r.task_ref ?? ""}`.trim();
// U4a (#1873): a built-in store task's write: what it did, under which verb, by which role or person.
case "store_tasks":
return [r.op, r.verb, `r${r.revision}`, `by ${r.by_role ?? r.by_human}`, r.fields?.title ? one(r.fields.title, 100) : null].filter(Boolean).join(" ");
default:
return "";
}
+11 -6
View File
@@ -43,12 +43,17 @@ function checkedState(org) {
if (s.host && !lock) fail('bus host lock is missing');
return s;
}
function answer(path) {
export function probeBusSocket(path, { signal, deadline } = {}) {
return new Promise(resolve => {
const remaining = deadline === undefined ? 1000 : Math.min(1000, deadline - Date.now());
if (signal?.aborted || remaining <= 0) return resolve(false);
let done = false, data = '';
const socket = connect(path);
const finish = value => { if (done) return; done = true; socket.destroy(); resolve(value); };
socket.setTimeout(1000, () => finish(false));
const abort = () => finish(false);
const finish = value => { if (done) return; done = true; signal?.removeEventListener('abort', abort); socket.destroy(); resolve(value); };
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) return abort();
socket.setTimeout(remaining, () => finish(false));
socket.on('error', () => finish(false));
socket.on('connect', () => socket.write('{}\n'));
socket.on('data', chunk => {
@@ -62,9 +67,9 @@ function answer(path) {
socket.on('end', () => finish(false));
});
}
export async function busStatus(ctx, id) {
export async function busStatus(ctx, id, bound) {
const org = await target(ctx, id), state = checkedState(org);
const healthy = Boolean(state.host?.live && state.socket && await answer(org.paths.socket));
const healthy = Boolean(state.host?.live && state.socket && await probeBusSocket(org.paths.socket, bound));
return { org: id, instance: ctx.instance, root: org.root, socket: org.paths.socket, healthy, pid: state.host?.pid ?? null, boot: readBootStatus(org.root) };
}
export async function busStop(ctx, id) {
@@ -123,7 +128,7 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
while (Date.now() < deadline && !exited) {
// During boot the host holds its lock before its state is published.
const state = readHostState(org.root);
if (ready && state?.live && state.pid === child.pid && state.business === id && await answer(org.paths.socket)) {
if (ready && state?.live && state.pid === child.pid && state.business === id && await probeBusSocket(org.paths.socket)) {
child.disconnect();
if (detach) child.unref();
const receipt = { org: id, pid: child.pid, socket: org.paths.socket, root: org.root };
+178 -31
View File
@@ -2,6 +2,7 @@
import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -64,29 +65,22 @@ function takeLock(ctx) {
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let child = null, done = null;
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
// own restart route (POST /api/console/restart, a human action) exits with it.
export const RESTART_EXIT = 75;
const sleep = ms => new Promise(r => setTimeout(r, ms));
let lastStamp = 0;
// One Console process: spawn it, wait for the URL it prints, check the address and port.
async function launch(ctx, args, lan, detach) {
lastStamp = Math.max(Date.now(), lastStamp + 1);
const log = join(directory(ctx), `console-${process.pid}-${lastStamp}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
let child;
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
try {
const prior = state(ctx);
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : [])];
const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
@@ -97,22 +91,99 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
const startTime = startTimeOf(child.pid);
if (!startTime) fail('console exited during startup');
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' });
if (detach) child.unref();
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, match[3]));
const result = { url: match[1], pid: child.pid, ...(lan ?? {}) };
Object.defineProperty(result, 'done', { value: done });
return result;
return { child, done, url: match[1], port: match[3], startTime };
}
await new Promise(r => setTimeout(r, 50));
await sleep(50);
}
fail(`console failed to start; see ${log}`);
} catch (e) {
if (child && child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
}
}
const record = (run, lan) => JSON.stringify({ pid: run.child.pid, startTime: run.startTime, url: run.url, ...(lan ?? {}) }) + '\n';
// F3: a relaunch keeps the arguments and the address. The state file must still name the process
// that asked for the restart; a stop that ran in between wins, and nothing is relaunched.
async function relaunch(ctx, old, args, lan) {
const next = args.map((a, i) => (args[i - 1] === '--port' ? old.port : a));
let release;
for (let n = 0; !release; n++) {
try { release = takeLock(ctx); } catch (e) { if (n >= 50) throw e; await sleep(100); }
}
try {
const s = state(ctx);
if (!s || s.pid !== old.child.pid || s.startTime !== old.startTime || s.url !== old.url) fail('console state changed during restart');
let fresh;
// The old listener has closed; a refused bind is retried briefly.
for (let n = 0; !fresh; n++) {
try { fresh = await launch(ctx, next, lan, false); } catch (e) { if (n >= 4) throw e; await sleep(500); }
}
try {
if (fresh.url !== old.url) fail('console restarted on another address');
const tmp = `${stateFile(ctx)}.tmp-${randomBytes(4).toString('hex')}`;
writeFileSync(tmp, record(fresh, lan), { mode: 0o600, flag: 'wx' });
renameSync(tmp, stateFile(ctx));
} catch (e) { fresh.child.kill('SIGTERM'); await fresh.done; throw e; }
return fresh;
} finally { release(); }
}
// Foreground supervision: relaunch on RESTART_EXIT, end on any other exit. `stop` forwards a
// stop signal (the unit's SIGTERM, a terminal's Ctrl-C) to the Console; a stop is a clean end.
function supervise(ctx, first, args, lan, warn) {
let current = first, stopping = false;
const stop = () => {
if (stopping) return;
stopping = true;
const child = current.child;
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGTERM');
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 15000).unref();
};
const done = (async () => {
for (;;) {
const code = await current.done;
if (stopping) return 0;
if (code !== RESTART_EXIT) return code;
warn('mosaic: console asked for a restart; relaunching on the same arguments\n');
try { current = await relaunch(ctx, current, args, lan); }
catch (e) { warn(`mosaic: console restart failed: ${e.message}\n`); return 1; }
if (stopping) { stopping = false; stop(); await current.done; return 0; }
}
})();
return { done, stop };
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
// this process relaunches it in place (F3). A detached Console has no supervisor.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let run, args;
try {
const prior = state(ctx);
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : []), ...(detach ? [] : ['--supervised'])];
run = await launch(ctx, args, lan, detach);
try { writeFileSync(stateFile(ctx), record(run, lan), { mode: 0o600, flag: 'wx' }); }
catch (e) { run.child.kill('SIGTERM'); await run.done; throw e; }
} finally { release(); }
if (detach) run.child.unref();
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, run.port));
const result = { url: run.url, pid: run.child.pid, ...(lan ?? {}) };
const supervision = detach ? { done: run.done, stop: () => run.child.kill('SIGTERM') } : supervise(ctx, run, args, lan, warn);
Object.defineProperty(result, 'done', { value: supervision.done });
Object.defineProperty(result, 'stop', { value: supervision.stop });
return result;
}
export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) {
const release = takeLock(ctx);
try {
@@ -149,3 +220,79 @@ export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {})
if (running && s.bind) warn(lanWarning(bind, port));
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
}
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
// terminal. Each one is a request to the running Console named by console.json, sent as the page
// sends it: the Console's own Host and Origin, Sec-Fetch-Site: same-origin and a JSON `{}` body.
// The Console's route applies its owner, --send and origin checks and writes the action trail;
// nothing here writes the trail, signals a process or touches a bus. The CLI verbs refuse inside
// an agent run before they get here (transport.mjs refuseInsideAgent).
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
function consoleTarget(ctx) {
const s = state(ctx);
if (!s) fail('no Console runs for this instance; start one with mosaic console start');
if (!liveConsole(s)) fail('the Console in console state is not running; see mosaic console status');
const url = /^http:\/\/([0-9.]+):(\d+)\/$/.exec(s.url);
if (!url) fail('console state is invalid');
const port = Number(url[2]);
if (forbidden.has(port)) fail('console state names a forbidden port');
// A LAN Console on 0.0.0.0 answers on loopback too; one bound to an address answers only there.
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
return { address, port, host: `${address}:${port}` };
}
function consoleCall(target, { method = 'GET', path, timeoutMs }) {
return new Promise((resolve, reject) => {
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) };
let sent = false;
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
let text = '';
res.setEncoding('utf8');
res.on('data', chunk => { text += chunk; if (text.length > 65536) req.destroy(new Error('the Console answer is too large')); });
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); });
res.on('error', e => reject(Object.assign(e, { sent: true })));
});
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
req.on('finish', () => { sent = true; });
req.on('error', e => reject(Object.assign(e, { sent })));
req.end(method === 'POST' ? '{}' : undefined);
});
}
// A refused connection never reached the Console. Anything else after the request left may have
// changed something, so it is outcome-unknown and nothing is retried.
async function consolePost(target, path, { timeoutMs, unknown }) {
let answer;
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); }
catch (e) {
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
throw new CliError(`outcome unknown: ${unknown}`, 1);
}
return answer;
}
function refusal(answer, what) {
const code = typeof answer.body?.error === 'string' && /^[a-z-]{1,64}$/.test(answer.body.error) ? answer.body.error : `http ${answer.status}`;
const message = typeof answer.body?.message === 'string' ? `: ${answer.body.message.slice(0, 500)}` : '';
return new CliError(`${what} refused (${code})${message}`, answer.status >= 500 ? 1 : 3);
}
// POST /api/console/restart, then wait for the relaunched Console (a new boot id on /api/config).
export async function consoleRestart(ctx, { timeoutMs = 10000, waitMs = 30000 } = {}) {
const target = consoleTarget(ctx);
const answer = await consolePost(target, '/api/console/restart', { timeoutMs, unknown: 'the Console may be restarting; check mosaic console status and the action trail' });
if (answer.status !== 202 || answer.body?.outcome !== 'restarting' || typeof answer.body.boot !== 'string') throw refusal(answer, 'console restart');
const from = answer.body.boot, deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
await sleep(250);
try {
const config = await consoleCall(target, { path: '/api/config', timeoutMs: 2000 });
if (config.status === 200 && typeof config.body?.boot === 'string' && config.body.boot !== from) return { outcome: 'restarted', from, boot: config.body.boot };
} catch { /* closed while it restarts */ }
}
throw new CliError('the Console accepted the restart but has not answered since; check mosaic console status', 1);
}
// POST /api/orgs/ID/restart/bus: a running bus restarts, a stopped one starts (org-admin.mjs restartBus).
export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
const target = consoleTarget(ctx);
const answer = await consolePost(target, `/api/orgs/${id}/restart/bus`, { timeoutMs, unknown: `the bus may be restarting; check mosaic bus status ${id} and the action trail` });
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
return answer.body;
}
+12 -2
View File
@@ -2,11 +2,14 @@ import { readFile, writeFile } from 'node:fs/promises';
import { configDir } from '../../organization/src/organization-config.mjs';
import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs';
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { CliError } from './errors.mjs';
import { busStopForDelete } from './org-bus.mjs';
export const ORG_USAGE = 'mosaic org lock show|clear ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
export async function orgCommand(args, io, deps = {}) {
@@ -16,12 +19,19 @@ export async function orgCommand(args, io, deps = {}) {
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
if (words[0] === 'template' && words[1] === 'render' && words.length === 3) { allowed([], ['--fixture', '--output']); result = await templateRender(words[2], { fixture: p.values['--fixture'] }); await writeFile(need('--output'), JSON.stringify(result, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); io.stdout.write(`rendered ${words[2]} to ${p.values['--output']}\n`); return; }
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
if (words[0] === 'reload') refuseInsideAgent(io.env);
if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; }
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
if (words[0] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; }
else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const r = p.values['--if-revision']; if (r !== undefined && !/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, confirm: need('--confirm'), ifRevision: r === undefined ? undefined : Number(r) }); }
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
else if (words[0] === 'show' && words.length === 2) { allowed(['--json'], []); result = await showOrg(ctx, words[1]); }
else if (words[0] === 'list' && words.length === 1) { allowed(['--json'], []); for (const entry of await listOrgs(ctx)) if (entry.state === 'active' || entry.state === 'archived') accepted.push(await showOrg(ctx, entry.id, { includeArchived: true })); result = accepted; }
else if ((words[0] === 'queue' && words.length === 3) || (words[0] === 'project' && words[1] === 'repo' && words.length === 5)) {
+15 -2
View File
@@ -14,6 +14,8 @@ export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester
// and check above work on the repository on disk and never contact the bus, except that
// init --tester (row 175, #1673) binds the tester once before it writes.
export const PROJECT_BUS_USAGE = [
'mosaic project system-types <name-or-id> [--business <id>] [--json]',
'mosaic project system-types-set <name-or-id> --types <comma-separated-ids> [--business <id>]',
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
'mosaic project list [--archived] [--business <id>] [--json] [--tester]',
'mosaic project archive <name-or-id> [--business <id>]',
@@ -28,8 +30,8 @@ export async function projectBusCommand(args, io, callFor) {
? { flags: ['--tester'], values: ['--repo', '--business'] }
: sub === 'list'
? { flags: ['--json', '--tester', '--archived'], values: ['--business'] }
: { flags: [], values: ['--business'] });
const one = sub === 'create' || sub === 'archive' || sub === 'unarchive';
: { flags: sub === 'system-types' ? ['--json'] : [], values: sub === 'system-types-set' ? ['--business', '--types'] : ['--business'] });
const one = ['create', 'archive', 'unarchive', 'system-types', 'system-types-set'].includes(sub);
if (parsed.positional.length !== (one ? 1 : 0))
throw new CliError(`project ${sub} takes ${one ? (sub === 'create' ? 'one name' : 'one name or id') : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4);
const call = callFor(parsed);
@@ -45,6 +47,17 @@ export async function projectBusCommand(args, io, callFor) {
}
return io.stdout.write(`created project ${name} id ${made.id}\n`);
}
if (sub === 'system-types' || sub === 'system-types-set') {
const projects = await call('project.list', { includeArchived: true });
const ref = parsed.positional[0];
const hit = projects.find(p => p.id === ref) ?? projects.find(p => p.name === ref);
if (!hit) throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}`, 2);
const write = sub === 'system-types-set';
if (write && parsed.values['--types'] === undefined) throw new CliError('--types is required (an empty string clears the list)', 4);
const systemTypes = parsed.values['--types'] ? parsed.values['--types'].split(',') : [];
const result = await call(write ? 'project.system-types.set' : 'project.system-types', { project: hit.id, ...(write ? { systemTypes } : {}) });
return io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
// #797 MVP3: the owner's reversible archive. Resolution reads the full list, archived included,
// so an archived project can be named for unarchive.
if (sub === 'archive' || sub === 'unarchive') {
+71
View File
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { accountsCommand } from '../src/accounts.mjs';
import { main } from '../src/cli.mjs';
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
function setup(t) {
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
}
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
});
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
});
test('host uid refusal happens before any lock, even on list or remove', async t => {
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
}
});
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
});
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
});
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
});
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
});
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
process.getuid = () => effectiveUid + 1;
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
finally { process.getuid = realUid; }
});
+15
View File
@@ -168,6 +168,21 @@ test("agents and tasks print through the broker", async (t) => {
assert.equal(tasks.io.out.text, "tasks: none\n");
});
test("U4a: tasks and trail print the organization's built-in store tasks", async (t) => {
const s = setup(t);
s.b.request(s.human, { verb: "tasks.create", args: { title: "Plan the store", requirement: "REQ-TASK-1" } });
s.b.request(s.human, { verb: "tasks.edit", args: { task_ref: "task:1", state: "in-progress" } });
const tasks = await s.run(["tasks", "--business", "demo"]);
assert.equal(tasks.code, 0, tasks.io.err.text);
assert.equal(tasks.io.out.text, "task:1 in-progress Plan the store\n");
const trail = await s.run(["trail", "task:1", "--business", "demo"]);
assert.equal(trail.code, 0, trail.io.err.text);
const lines = trail.io.out.text.trimEnd().split("\n");
assert.equal(lines[0], `trail task:1: ${s.read("trail", { subject: "task:1" }).length} row(s)`);
assert.ok(lines.some((l) => / {2}store_tasks#1 {2}create {2}tasks\.create {2}r1 {2}by jason {2}Plan the store$/.test(l)), trail.io.out.text);
assert.ok(lines.some((l) => / {2}store_tasks#2 {2}change {2}tasks\.edit {2}r2 {2}by jason {2}Plan the store$/.test(l)), trail.io.out.text);
});
test("notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes", (t) => {
const dataRoot = tmp(t);
assert.throws(() => readNotifyConfig(dataRoot, "acme"), (e) => e.exitCode === 3 && /no notifier config/.test(e.message));
+187
View File
@@ -0,0 +1,187 @@
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
// terminal. Each test runs the real scripts/mosaic on a scratch instance made by bootstrap.sh, and
// checks the refusal before any read, the request to the Console's own route and the action trail
// that route writes. The supervised Console is the real one; the org reload success path runs
// against an in-process Console whose bus service is a fake, so no bus host starts.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
import { processState } from '../../seat/src/process-state.mjs';
import { startServer } from '../../webui/src/serve.mjs';
import { createDevOwner } from '../../webui/src/dev-owner.mjs';
import { createOrgAdmin } from '../../webui/src/org-admin.mjs';
import { actionTrail } from '../../webui/src/action-trail.mjs';
import { close } from '../../webui/tests/fixture.mjs';
import { humanEnv } from './helpers.mjs';
import { consoleStart, consoleStop } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const MOSAIC = join(REPO, 'scripts/mosaic');
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
// A scratch instance, as bootstrap.sh makes one, with org alpha active.
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 'u1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-u1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'u1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-u1test'), dataRoot: join(home, '.mosaic-u1test'), instance: 'u1test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
return { ctx, env, trailFile: join(ctx.dataRoot, 'console/actions.jsonl') };
}
// The CLI as a child, without blocking this process: the in-process Console must keep answering.
function mosaic(env, args, timeout = 60000) {
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
child.on('error', reject);
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
});
}
const trailLines = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }) : [];
function get(port, path) {
return new Promise((resolve, reject) => {
request({ host: '127.0.0.1', port, path, headers: { host: `127.0.0.1:${port}` } }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}).on('error', reject).end();
});
}
// console.json naming a Console that runs in this test process (same checks as a real one).
function recordConsole(ctx, url, { pid = process.pid, startTime = processState(process.pid).startTime } = {}) {
const dir = join(ctx.dataRoot, 'console');
mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700);
const file = join(dir, 'console.json');
writeFileSync(file, JSON.stringify({ pid, startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600);
}
test('both verbs refuse inside an agent run before any config is read', async () => {
// The config path is not an instance config, so a read would refuse with another message.
// humanEnv strips agent markers, so the marker goes in after it.
const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/u1/config.json' }), CLAUDECODE: '1' };
for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) {
const r = await mosaic(env, args);
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/);
assert.equal(r.stdout, '');
}
});
test('with no live Console both verbs refuse and nothing is written', { timeout: 90000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) {
const r = await mosaic(env, args);
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, /no Console runs for this instance/);
}
recordConsole(ctx, 'http://127.0.0.1:1/', { pid: 2147483647, startTime: '1' });
const stale = await mosaic(env, ['console', 'restart']);
assert.equal(stale.status, 3, stale.stderr);
assert.match(stale.stderr, /is not running/);
const bad = await mosaic(env, ['org', 'reload', 'Bad/Id']);
assert.equal(bad.status, 4, bad.stderr);
assert.match(bad.stderr, /invalid organization id/);
assert.equal((await mosaic(env, ['console', 'restart', 'extra'])).status, 4);
assert.equal(existsSync(trailFile), false);
});
test('mosaic console restart relaunches a supervised Console and the trail records it', { timeout: 120000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { result.stop(); await result.done; });
const port = Number(new URL(result.url).port);
assert.ok(!FORBIDDEN.includes(port));
const before = (await get(port, '/api/config')).body;
assert.equal(before.consoleRestart, true);
const r = await mosaic(env, ['console', 'restart']);
assert.equal(r.status, 0, r.stderr);
const answer = JSON.parse(r.stdout);
assert.equal(answer.outcome, 'restarted'); assert.equal(answer.from, before.boot);
assert.equal(typeof answer.boot, 'string'); assert.notEqual(answer.boot, before.boot);
assert.equal((await get(port, '/api/config')).body.boot, answer.boot);
assert.deepEqual(trailLines(trailFile), [{ by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }]);
assert.equal(statSync(trailFile).mode & 0o777, 0o600);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
});
test('a detached Console refuses mosaic console restart, and org reload of an unknown org, with no trail', { timeout: 90000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
const port = Number(new URL(result.url).port);
assert.ok(!FORBIDDEN.includes(port));
const r = await mosaic(env, ['console', 'restart']);
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
// The real organization service refuses an org that does not exist before any trail line.
const unknown = await mosaic(env, ['org', 'reload', 'nosuch']);
assert.equal(unknown.status, 3, unknown.stderr);
assert.match(unknown.stderr, /org reload refused \(org-[a-z-]+\)/);
assert.equal((await get(port, '/api/config')).status, 200);
assert.equal(existsSync(trailFile), false);
assert.equal((await consoleStop(ctx)).stopped, true);
});
// The F3 bus control through the Console's own route: a stopped bus starts and a running one restarts.
function fakeAdmin(ctx) {
const calls = [];
let running = false;
const known = id => { if (id !== 'acme') throw Object.assign(new Error('unknown organization'), { code: 'org-unknown' }); };
const service = { showOrg: async (c, id) => { known(id); return { id, root: join(ctx.dataRoot, 'org-runtime', id), revision: 1, doc: {}, principal: c.principal }; } };
const bus = {
busStatus: async (c, id) => { known(id); return { org: id, instance: ctx.instance, root: join(ctx.dataRoot, 'org-runtime', id), socket: '/run/u1.sock', healthy: running, pid: running ? 4242 : null }; },
busStart: async (c, id, opts) => { calls.push(['busStart', id, opts]); running = true; return { org: id, pid: 4242, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; },
busRestart: async (c, id) => { calls.push(['busRestart', id]); return { org: id, pid: 4343, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; },
};
const registry = { listOrgs: async () => [{ id: 'acme', state: 'active' }] };
return { calls, admin: createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance }) }) };
}
test('mosaic org reload starts or restarts the bus through the Console route and the trail records each', { timeout: 90000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
const f = fakeAdmin(ctx);
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
t.after(() => close(server));
const port = server.address().port;
assert.ok(!FORBIDDEN.includes(port));
recordConsole(ctx, `http://127.0.0.1:${port}/`);
let r = await mosaic(env, ['org', 'reload', 'acme']);
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout), { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '<host path>', root: '<data root>/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' });
r = await mosaic(env, ['org', 'reload', 'acme']);
assert.equal(r.status, 0, r.stderr);
const second = JSON.parse(r.stdout);
assert.equal(second.restarted, true); assert.equal(second.verb, 'mosaic bus restart acme'); assert.equal(second.receipt.pid, 4343);
assert.deepEqual(f.calls, [['busStart', 'acme', { detach: true, devLocalOwner: true, pm: false }], ['busRestart', 'acme']]);
// An unknown org is refused by the route before the trail (as org-unavailable, which does not say
// whether it exists); extra arguments never reach the Console.
const unknown = await mosaic(env, ['org', 'reload', 'other']);
assert.equal(unknown.status, 3, unknown.stderr);
assert.match(unknown.stderr, /org reload refused \(org-unavailable\)/);
assert.equal((await mosaic(env, ['org', 'reload', 'acme', 'extra'])).status, 4);
assert.deepEqual(trailLines(trailFile), [
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
]);
// A read-only Console (no --send) refuses, and the CLI reports the Console's own reason.
await close(server);
const readonly = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
t.after(() => close(readonly));
recordConsole(ctx, `http://127.0.0.1:${readonly.address().port}/`);
const denied = await mosaic(env, ['org', 'reload', 'acme']);
assert.equal(denied.status, 3, denied.stderr);
assert.match(denied.stderr, /org reload refused \(read-only\): start the Console with --send/);
assert.equal(trailLines(trailFile).length, 4);
});
+11
View File
@@ -37,3 +37,14 @@ test("the trail keeps the broker's order and names a decision's task without its
test("tasks print the tracker fields the snapshot carries", () => {
assert.equal(formatTasks([{ task_ref: "vikunja:1/7", fields: { title: "Build S4", done: false } }]), "vikunja:1/7 open Build S4\n");
});
test("U4a: store tasks print like tracker tasks, and their trail rows name the write", () => {
assert.equal(formatTasks([{ task_ref: "task:3", source: "store", fields: { title: "Plan", state: "in-progress", done: false } }]), "task:3 in-progress Plan\n");
const rows = [
{ at: "2026-11-09T10:00:00Z", table: "store_tasks", seq: 1, op: "create", verb: "task.create", revision: 1, by_role: "pm", by_human: null, fields: { title: "Plan" } },
{ at: "2026-11-09T11:00:00Z", table: "store_tasks", seq: 2, op: "archive", verb: "tasks.archive", revision: 2, by_role: null, by_human: "jason", fields: { title: "Plan" } },
];
const lines = formatTrail("task:3", rows).trimEnd().split("\n");
assert.equal(lines[1], "2026-11-09T10:00:00Z store_tasks#1 create task.create r1 by pm Plan");
assert.equal(lines[2], "2026-11-09T11:00:00Z store_tasks#2 archive tasks.archive r2 by jason Plan");
});
+78 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs';
import { get, request } from 'node:http';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@@ -133,3 +133,80 @@ test('mosaic console start refuses the LAN flags without the dev owner and allow
assert.deepEqual(JSON.parse(none.stdout), { running: false });
assert.equal(mosaic(['console', 'status', '--json']).status, 4);
});
// F3: the owner restarts the Console from Settings. A foreground `mosaic console start` runs the
// Console with --supervised and relaunches it on the same arguments and address when it exits 75.
const WEBUI = join(REPO, 'packages/webui/src/cli.mjs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const argvOf = pid => readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
function call(port, path, { method = 'GET', body = null, headers = {} } = {}) {
const h = { host: `127.0.0.1:${port}`, ...(method === 'GET' ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
return new Promise((resolve, reject) => {
const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, body: json }); });
});
r.on('error', reject); r.end(body ?? undefined);
});
}
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
test('a foreground console relaunches on the same address when the owner restarts it, and a stop ends it', { timeout: 90000 }, async t => {
const { ctx } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { result.stop(); await result.done; });
const port = Number(new URL(result.url).port);
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port));
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
const before = (await call(port, '/api/config')).body;
assert.equal(before.consoleRestart, true); assert.equal(typeof before.boot, 'string');
const stateFile = join(ctx.dataRoot, 'console/console.json');
const trailFile = join(ctx.dataRoot, 'console/actions.jsonl');
// Another origin or site restarts nothing.
for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) {
assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers));
}
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).pid, result.pid);
assert.equal(existsSync(trailFile), false);
const answer = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
assert.equal(answer.status, 202);
assert.deepEqual(answer.body, { outcome: 'restarting', boot: before.boot });
let saved;
for (const deadline = Date.now() + 30000; ; await sleep(100)) {
saved = JSON.parse(readFileSync(stateFile, 'utf8'));
if (saved.pid !== result.pid) break;
if (Date.now() > deadline) assert.fail('the Console was not relaunched');
}
// Same address, same arguments; the auto port is now the port it had.
assert.equal(saved.url, result.url);
assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']);
assert.equal(statSync(stateFile).mode & 0o777, 0o600);
const after = (await call(port, '/api/config')).body;
assert.notEqual(after.boot, before.boot);
assert.equal(after.consoleRestart, true);
const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
assert.deepEqual(more, []);
assert.ok(Number.isFinite(Date.parse(line.at)));
assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot });
assert.equal(statSync(trailFile).mode & 0o777, 0o600);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' });
result.stop();
assert.equal(await result.done, 0);
assert.equal(consoleStatus(ctx).running, false);
});
test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
const { ctx } = await instance(t);
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
const port = Number(new URL(result.url).port);
assert.ok(!argvOf(result.pid).includes('--supervised'));
assert.equal((await call(port, '/api/config')).body.consoleRestart, false);
const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised');
assert.equal((await call(port, '/api/config')).status, 200);
assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false);
assert.equal((await consoleStop(ctx)).stopped, true);
});
+1
View File
@@ -12,6 +12,7 @@ test('organization CLI renders, validates, creates, updates and initializes git'
const file = join(home, 'alpha.json'); const invoke = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return r.stdout; };
invoke('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
invoke('org', 'validate', file); invoke('org', 'create', 'alpha', '--from', file);
assert.deepEqual(JSON.parse(invoke('org', 'system-types', 'enabled', 'alpha')), { organization: 'alpha', enabled: [] });
const bootCheck = spawnSync(process.execPath, ['--input-type=module', '-e', `import {loadSystem, bootConfig} from './packages/cli/src/config.mjs'; const system=loadSystem({env:process.env, instance:'orgtest'}); bootConfig({system,businessId:'alpha',env:process.env});`], { cwd: repo, env, encoding: 'utf8' }); assert.equal(bootCheck.status, 0, bootCheck.stderr);
let info = JSON.parse(invoke('org', 'show', 'alpha', '--json')); assert.equal(info.id, 'alpha'); assert.equal(JSON.parse(invoke('org', 'list', '--json')).length, 1);
invoke('org', 'update', 'alpha', '--from', file, '--if-revision', String(info.revision));
+9 -6
View File
@@ -306,7 +306,7 @@ test('#811 migration readers follow the engine order and refuse malformed pairs'
assert.notEqual(engineSource,source);
const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`);
const pairs=engine.migrations.map(({from,to})=>[from,to]);
assert.equal(pairs.length,18);assert.deepEqual(pairs.at(-1),['3s','3t']);
assert.equal(pairs.length,20);assert.deepEqual(pairs.at(-1),['3u','3v']);
const release=readFileSync(join(ROOT,'scripts/stack-release.sh'),'utf8');
const fn=(name)=>{const m=release.match(new RegExp(`^${name}\\(\\) \{[\\s\\S]*?^\}`, 'm'));assert.ok(m,name);return m[0]};
const functions=['copy_schema','target_schema','plan_steps'].map(fn).join('\n');
@@ -885,7 +885,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events'];
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_system_types'];
const LATE='2999-01-01T00:00:00.000Z';
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
@@ -903,7 +903,7 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT
both(store('3f.sqlite',['events'],['events','instance_names']),'1\t1 event(s)\n');
both(store('none.sqlite',[],['instance_names']),'0\t0 event(s)\n');
});
for(const x of ['instance_names','usage_records'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
for(const x of ['instance_names','usage_records','project_system_types'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
const e=await mk(t);await seed(e);
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
addRow(e.store,x);
@@ -925,10 +925,10 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT
assert.equal(r.code,3,r.out+r.err);assert.ok(r.err.endsWith(`stack-release: refusing restore-mismatch: cannot count the rows after ${stamp}\n`),r.err);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).at(-1).code,'restore-mismatch');
});
await t.test('the in-run recount counts the listed tables too',async(t)=>{
for(const x of ['thread_opens','project_system_types'])await t.test(`the in-run recount counts the listed tables too (${x})`,async(t)=>{
const e=await mk(t);await seed(e);
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
fault(e,'stop-writes-row','thread_opens');
fault(e,'stop-writes-row',x);
const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,1,r.out+r.err);
assert.equal(r.err,rolledBack('post-backup-events',C));
assert.ok(r.out.includes('; 0 event(s) were written after the backup\n'),r.out);
@@ -943,7 +943,10 @@ test('T46 row 181 item 4: every table in the bus schema is in exactly one of fou
const db=new DatabaseSync(':memory:');db.exec(readFileSync(join(ROOT,'packages','bus','schema.sql'),'utf8'));
const tables=db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r)=>r.name);
const hasAt=(n)=>db.prepare(`SELECT count(*) AS n FROM pragma_table_info(?) WHERE name='at'`).get(n).n===1;
const always={decisions:'decision.raise writes action.allowed in the same transaction'};
const always={
decisions:'decision.raise writes action.allowed in the same transaction',
store_tasks:'U4a #1873: each row goes in one transaction with the action.allowed or human.input events row it cites',
};
const untimed={
gitea_grants:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
gitea_users:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orgCommand } from '../src/org.mjs';
import { projectBusCommand } from '../src/project.mjs';
test('org installed system types and project selection reads print JSON', async () => {
let output = '';
const io = { stdout: { write: s => { output += s; } }, env: {} };
await orgCommand(['system-types', 'installed'], io);
assert.deepEqual(JSON.parse(output).map(x => x.id), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
output = '';
const calls = [];
await projectBusCommand(['system-types', 'work', '--business', 'demo', '--json'], io, () => async (verb, args) => {
calls.push([verb, args]);
return verb === 'project.list' ? [{ id: 'project-id', name: 'work' }] : { project: 'project-id', selected: ['coding'], active: ['coding'], inactive: [] };
});
assert.deepEqual(calls.map(x => x[0]), ['project.list', 'project.system-types']);
assert.deepEqual(JSON.parse(output).active, ['coding']);
});
// sys1-review P2: both commands go through run() and the real CLI channel module, so a verb the
// channel does not admit fails here. A disabled channel and an agent run still refuse.
test('project system-types and system-types-set pass the CLI channel', async t => {
const { run } = await import('../src/cli.mjs');
const { fixture, humanEnv, io: mkio, tmp } = await import('./helpers.mjs');
const root = tmp(t), f = fixture(root);
const P = '10000000-0000-4000-8000-000000000001';
const transportFor = channels => {
const calls = [];
const transport = () => async (verb, args) => {
if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: channels } };
calls.push([verb, args]);
if (verb === 'project.list') return [{ id: P, at: '2026-11-09T00:00:00.000Z', name: 'work', repo: null }];
if (verb === 'project.system-types') return { project: P, selected: ['coding'], active: ['coding'], inactive: [] };
if (verb === 'project.system-types.set') return { project: P, selected: args.systemTypes, active: args.systemTypes, inactive: [] };
throw Object.assign(new Error(verb), { code: 'bus-refused' });
};
return { calls, transport };
};
const read = transportFor(['cli']), rx = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], rx, { transport: read.transport }), 0, rx.err?.text);
assert.deepEqual(read.calls, [['project.list', { includeArchived: true }], ['project.system-types', { project: P }]]);
assert.deepEqual(JSON.parse(rx.out.text).active, ['coding']);
const write = transportFor(['cli']), wx = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types-set', 'work', '--types', 'coding,marketing', '--business', 'acme'], wx, { transport: write.transport }), 0, wx.err?.text);
assert.deepEqual(write.calls.at(-1), ['project.system-types.set', { project: P, systemTypes: ['coding', 'marketing'] }]);
const off = transportFor(['webui']), ox = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], ox, { transport: off.transport }), 3);
assert.deepEqual(off.calls, []);
const agent = transportFor(['cli']);
assert.notEqual(await run(['project', 'system-types-set', 'work', '--types', 'coding', '--business', 'acme'], mkio({ ...humanEnv(f.env), CODEX_THREAD_ID: 'fixture-agent' }), { transport: agent.transport }), 0);
assert.deepEqual(agent.calls, []);
});
+45 -13
View File
@@ -5,7 +5,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { existsSync, lstatSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
@@ -124,10 +124,10 @@ test("row 175: --tester on limits set, reset and migrate and every write form ou
// W7 through the real tester transport: the CLI runs as its own process, so the tester-cli.mjs it
// spawns can reach the runtime in this one.
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
function mosaic(args, env) {
function command(executable, args, env) {
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k)));
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env: clean, timeout: 60000 });
const child = spawn(executable, args, { env: clean, timeout: 60000 });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
@@ -136,6 +136,8 @@ function mosaic(args, env) {
child.on("close", (status) => resolve({ status, out, err }));
});
}
const mosaic = (args, env) => command(MOSAIC, args, env);
// sage-1081: these six fell through to the seat CLI, which refused with its own usage, so W1
// (`plugins set`) and W4 (`name`) failed through the wrapper. Each now reaches packages/cli.
test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli, not the seat CLI", (t) => {
@@ -157,36 +159,55 @@ test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and th
});
const listing = (dir) => readdirSync(dir, { recursive: true }).map(String).sort();
// Names, types and file bytes: a rewrite of an existing file shows up, not just a new path.
const contents = (dir) => listing(dir).map((name) => {
const path = join(dir, name), st = lstatSync(path);
return [name, st.isFile() ? readFileSync(path).toString("base64") : st.isDirectory() ? "dir" : `mode ${st.mode}`];
});
async function w7(t, vars, edit) {
const { root, f, org } = await orgFixture(t, "acme", edit);
const rt = await startBroker({ dataRoot: org.root, businesses: bus(vars) });
const root = tmp(t, "w7-");
const f = fixture(root, "acme", edit);
const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(vars) });
t.after(() => rt.close());
return { root, f, agent: { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } };
// Production CLI routes its transport through an organization. Inject the real tester transport
// in a child so the legacy non-org project path can still exercise the broker and its proof.
const driver = join(root, "driver.mjs");
writeFileSync(driver, `
import { run } from ${JSON.stringify(new URL("../src/cli.mjs", import.meta.url).href)};
import { testerTransport } from ${JSON.stringify(new URL("../src/transport.mjs", import.meta.url).href)};
const [socket, ...args] = process.argv.slice(2);
process.exitCode = await run(args, undefined, {
testerTransport: business => testerTransport({ socket, business }),
});
`);
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
const project = (args, env = agent) => command(process.execPath, [driver, join(f.dataRoot, "bus", "broker.sock"), ...args], env);
return { root, f, agent, project };
}
test("row 175 W7: project init --tester makes the repository under the project area, committed by tester", async (t) => {
const { f, agent } = await w7(t);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
const { f, agent, project } = await w7(t);
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 0, r.err);
const repo = join(f.dataRoot, "projects", "acme", "stack");
assert.match(r.out, new RegExp(`^initialized ${repo}\ncommit [0-9a-f]{40}\n$`));
const log = spawnSync("git", ["-C", repo, "log", "--format=%an|%cn"], { encoding: "utf8" });
assert.equal(log.stdout, "tester|tester\n");
// Without --tester the same init inside an agent refuses, as before.
const again = await mosaic(["project", "init", "acme", "stack"], agent);
const again = await project(["project", "init", "acme", "stack"], agent);
assert.equal(again.status, 3);
assert.match(again.err, /outside any agent run/);
});
test("row 175 W7: a repo outside the project area refuses tester-project-area before any bus call, and nothing changes", async (t) => {
// The switch is off: a bind would refuse tester-off (exit 3), so exit 2 shows the area check ran first.
const { f, agent } = await w7(t, null, (doc) => {
const { f, agent, project } = await w7(t, null, (doc) => {
doc.projects.stack.repo = join(doc.projects.stack.root, "..", "records");
return doc;
});
const records = f.doc.projects.stack.repo;
const data = listing(f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 2, r.err);
assert.match(r.err, /tester-project-area/);
assert.equal(existsSync(records), false);
@@ -196,7 +217,7 @@ test("row 175 W7: a repo outside the project area refuses tester-project-area be
test("row 175 W7: the tester bind refuses tester-off and tester-required before any directory", async (t) => {
const off = await w7(t, null);
const data = listing(off.f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], off.agent);
const r = await off.project(["project", "init", "acme", "stack", "--tester"], off.agent);
assert.equal(r.status, 3, r.err);
assert.match(r.err, /tester-off/);
assert.deepEqual(listing(off.f.dataRoot), data);
@@ -204,10 +225,21 @@ test("row 175 W7: the tester bind refuses tester-off and tester-required before
const on = await w7(t);
const before = listing(on.f.dataRoot);
for (const marker of ["MOSAIC_RUN_ID", "MOSAIC_BUS_CAP", "MOSAIC_AGENT_RUN"]) {
const under = await mosaic(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
const under = await on.project(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
assert.equal(under.status, 3, `${marker}: ${under.err}`);
assert.match(under.err, /tester-required/, marker);
}
assert.deepEqual(listing(on.f.dataRoot), before);
assert.equal(existsSync(join(on.f.dataRoot, "projects")), false);
});
// Organization setup deliberately refuses tester init before creating a project record.
test("organization project init --tester refuses without writing", async (t) => {
const { f, org } = await orgFixture(t);
const before = contents(org.root);
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 3, r.err);
assert.match(r.err, /organization project init --tester is unavailable/);
assert.deepEqual(contents(org.root), before);
});
+49 -2
View File
@@ -7,8 +7,9 @@ stay as the old names: each module there re-exports the module of the same name
here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its
`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`,
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
addenda A and B. The package reads files and refuses on any problem. It
never writes, and it never opens a token file.
addenda A and B. Configuration readers refuse on any problem. Runtime
organization and accounts stores provide explicit locked writes; this package
never opens a token file.
```sh
scripts/mosaic org config validate mosaic-stack
@@ -578,3 +579,49 @@ The local development owner is accepted only through the existing named-instance
selection on this release's throwaway development instances. Receipt subjects
name that principal, not Jason. The dev-purpose bootstrap fence is a separate
follow-up before person-used deployment.
The optional `plugins.system-type` list enables installed system-type modules for
an organization. Missing means none. Unknown ids refuse validation. Changes
are owner-only; project selections are separate bus settings and a selection
whose type is disabled remains recorded as inactive.
## Instance accounts (Phase M2)
`src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or
JSON schema engine, as `access.mjs` does. `checkAccounts(document, {providers})`
and `loadAccounts(file, {providers})` return frozen documents. Providers are the
configured `console.sso.providers` objects (including issuer). Reads refuse an
unsafe config directory, unsafe file, unknown field or malformed row. Account
ids may be organization humans. `accountsRepairs(document)` reports unknown
roles and rights retained on read for rollback; these rows must grant nothing.
M4 supplies the authorization evaluator; M2 alone grants no Console or bus access.
`withAccounts(ctx, {revision, verb, actor?, registry?, init?}, mutate)` takes
`accounts.lock`, reloads, checks the expected file revision, awaits `mutate` on a
copy, maintains `authGen` and membership `rev`, validates and atomically replaces
0600 bytes. Init publishes with a no-clobber hard link; replacements use rename.
Both fsync the config directory before the receipt is appended. `ctx` contains `configDir`, `instance`, and `providers`. Init requires
revision 0 and an absent file. Other writes require an existing valid file. Writes
refuse unknown rights/roles on new or changed rows. Unchanged rollback rows pass
through exactly, so they cannot block account revocation or another row's repair. Role templates are never evaluated as authority by this store.
For a caller already holding accounts.lock,
`withAccountsLock(ctx, handle => withAccountsLocked(handle, options, mutate))`
reuses that live opaque handle. Never acquire an earlier lock from the callback.
The config path must have no symlink ancestors, including the home directory.
The order is lifecycle, registry, accounts. Registry-dependent writes pass the
live document from `withRegistry`; `readRegistryLocked(registry, ctx?)` verifies
the lock and rereads its persisted snapshot without relocking. A changed registry
or an instance mismatch refuses before account publication. Callbacks cannot
reuse a released handle, or run two writes at once through one handle.
Each committed write appends and fsyncs one receipt under the accounts lock in
`accounts-receipts.jsonl`: effective OS uid, actor id, verb, old/new file revisions, changed
account ids with changed field names, and before/after membership rights, scope
and revisions. No account
name, subject, invite binding, hash, raw token or cookie enters the receipt. The
store preflights the receipt destination before replacing accounts. As with
other two-file journals, a crash or IO failure between replacement and receipt
append can leave committed state without its receipt; an observed append failure
has `committed: true` and must be inspected before retrying. It never rolls back
committed authority silently or fabricates a success receipt.
+266
View File
@@ -0,0 +1,266 @@
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
import { constants, lstatSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import { dirname, join, parse, resolve } from 'node:path';
import { randomBytes } from 'node:crypto';
import { OrgError, readRegistryLocked } from './org-registry.mjs';
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
const fail = (code, message = code) => { throw new OrgError(code, message); };
const bad = message => fail('accounts-invalid', message);
const USER_ID = /^u-[0-9a-f]{12}$/;
const HEX32 = /^[0-9a-f]{32}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
const array = (x, at) => { if (!Array.isArray(x)) bad(`${at} must be an array`); return x; };
const text = (x, at) => {
requireString(x, at, { max: 255 });
if (/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(x)) bad(`${at} must not contain control or bidi formatting characters`);
return x;
};
const keys = (x, allowed, at) => { requireObject(x, at); rejectUnknownKeys(x, allowed, at); };
const accountId = (x, at) => { if (typeof x !== 'string' || !USER_ID.test(x)) bad(`${at} must be u- plus 12 lowercase hex characters`); };
function time(x, at) {
if (typeof x !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x) || !Number.isFinite(Date.parse(x)) || new Date(x).toISOString().slice(0, 19) !== x.slice(0, 19)) bad(`${at} must be a real ISO time`);
}
function providerMap(context) {
const providers = new Map();
for (const p of array(context?.providers, 'accounts context.providers')) {
text(p.id, 'provider.id');
if (p.id === 'local-dev' || typeof p.issuer !== 'string' || !p.issuer.startsWith('https://')) bad('provider must have an https issuer and must not be local-dev');
try { if (new URL(p.issuer).protocol !== 'https:') bad('invalid issuer'); } catch { bad('invalid issuer'); }
if (providers.has(p.id)) bad('duplicate configured provider');
providers.set(p.id, p);
}
return providers;
}
function subject(s, at, providers) {
keys(s, ['provider', 'sub'], at);
if (s.provider === 'local-dev' || !providers.has(s.provider)) bad(`${at}.provider must be a configured provider, never local-dev`);
text(s.sub, `${at}.sub`);
}
function scope(s, at) {
keys(s, ['projects', 'instances', 'expires'], at);
for (const key of ['projects', 'instances']) {
const entries = requireDistinctList(s[key], `${at}.${key}`, x => {
if (x === '*') return;
if (key === 'projects') { if (typeof x !== 'string' || !UUID.test(x)) bad(`${at}.projects must contain UUIDs`); }
else requireId(x, `${at}.instances`);
}, { nonEmpty: true });
if (entries.includes('*') && entries.length !== 1) bad(`${at}.${key} must use "*" alone`);
}
if (s.expires !== undefined) time(s.expires, `${at}.expires`);
}
function rowRights(row, at) {
requireId(row.role, `${at}.role`);
if (row.role === 'owner') bad(`${at}.role is not grantable`);
const rights = requireDistinctList(row.rights, `${at}.rights`, r => {
text(r, `${at}.rights`);
if (r === 'owner' || (Object.hasOwn(RIGHTS, r) && !RIGHTS[r].grantable)) bad(`${at}.rights is not grantable`);
if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/.test(r)) bad(`${at}.rights contains an invalid name`);
}, { nonEmpty: true });
if (!rights.includes('org.view') || !same(rights, [...rights].sort())) bad(`${at}.rights must be sorted and contain org.view`);
scope(row.scope, `${at}.scope`);
requireId(row.org, `${at}.org`);
if (typeof row.incarnation !== 'string' || !HEX32.test(row.incarnation)) bad(`${at}.incarnation must be 32 lowercase hex characters`);
}
// Pure whole-file validation. Unknown, well-formed rights/roles survive a rollback;
// accountsRepairs exposes them without changing the persisted schema.
export function checkAccounts(document, context) {
const providers = providerMap(context);
keys(document, ['accountsVersion', 'revision', 'users', 'memberships', 'invites'], 'accounts file');
if (document.accountsVersion !== 1) bad('accountsVersion must be 1');
requirePositiveInt(document.revision, 'revision');
const users = new Set(), subjects = new Set(); let operators = 0;
array(document.users, 'users').forEach((u, i) => {
const at = `users[${i}]`;
keys(u, ['id', 'subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'], at);
accountId(u.id, `${at}.id`); if (users.has(u.id)) bad('duplicate account id'); users.add(u.id);
text(u.name, `${at}.name`);
if (!['operator', 'user'].includes(u.instanceRole)) bad(`${at}.instanceRole must be operator or user`);
if (!['active', 'disabled'].includes(u.state)) bad(`${at}.state must be active or disabled`);
requirePositiveInt(u.authGen, `${at}.authGen`); time(u.created, `${at}.created`);
if (u.instanceRole === 'operator' && u.state === 'active') operators++;
array(u.subjects, `${at}.subjects`).forEach((s, j) => {
subject(s, `${at}.subjects[${j}]`, providers); const key = JSON.stringify([s.provider, s.sub]);
if (subjects.has(key)) bad('subject appears more than once in the file'); subjects.add(key);
});
if (!u.subjects.length) bad(`${at}.subjects must not be empty`);
});
if (!operators) bad('at least one active operator must exist');
const memberships = new Set();
array(document.memberships, 'memberships').forEach((m, i) => {
const at = `memberships[${i}]`;
keys(m, ['org', 'incarnation', 'user', 'role', 'rights', 'scope', 'rev', 'added', 'by', 'via'], at);
rowRights(m, at); accountId(m.user, `${at}.user`); if (!users.has(m.user)) bad(`${at}.user must name an account`);
accountId(m.by, `${at}.by`); text(m.via, `${at}.via`); time(m.added, `${at}.added`); requirePositiveInt(m.rev, `${at}.rev`);
const key = JSON.stringify([m.org, m.incarnation, m.user]); if (memberships.has(key)) bad('duplicate membership'); memberships.add(key);
});
const invites = new Set(), hashes = new Set();
array(document.invites, 'invites').forEach((invite, i) => {
const at = `invites[${i}]`;
keys(invite, ['id', 'org', 'incarnation', 'role', 'rights', 'scope', 'bind', 'tokenHash', 'by', 'created', 'expires', 'state', 'user', 'used'], at);
rowRights(invite, at); text(invite.id, `${at}.id`); if (!UUID.test(invite.id)) bad(`${at}.id must be a UUID`);
if (invites.has(invite.id)) bad('duplicate invite id'); invites.add(invite.id);
if (typeof invite.tokenHash !== 'string' || !/^[0-9a-f]{64}$/.test(invite.tokenHash) || hashes.has(invite.tokenHash)) bad(`${at}.tokenHash must be a unique sha256 hex hash`); hashes.add(invite.tokenHash);
accountId(invite.by, `${at}.by`); time(invite.created, `${at}.created`); time(invite.expires, `${at}.expires`);
if (Date.parse(invite.expires) <= Date.parse(invite.created)) bad(`${at}.expires must follow created`);
const ttl = Date.parse(invite.expires) - Date.parse(invite.created);
if (ttl < 3600000 || ttl > 168 * 3600000) bad(`${at} validity must be between 1 and 168 hours`);
if (!['open', 'used', 'revoked', 'declined', 'expired'].includes(invite.state)) bad(`${at}.state is invalid`);
requireObject(invite.bind, `${at}.bind`);
if (Object.hasOwn(invite.bind, 'email')) { keys(invite.bind, ['email'], `${at}.bind`); text(invite.bind.email, `${at}.bind.email`); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invite.bind.email)) bad(`${at}.bind.email must be one address`); }
else subject(invite.bind, `${at}.bind`, providers);
if (invite.state === 'used') { accountId(invite.user, `${at}.user`); time(invite.used, `${at}.used`); }
else if (invite.user !== undefined || invite.used !== undefined) bad(`${at} unused invite has usage fields`);
});
return deepFreeze(structuredClone(document));
}
export function accountsRepairs(doc) {
const result = [];
for (const [kind, rows] of [['membership', doc.memberships], ['invite', doc.invites]]) for (const row of rows) {
const unknownRole = Object.hasOwn(ROLE_RIGHTS, row.role) ? undefined : row.role;
const unknownRights = row.rights.filter(r => !Object.hasOwn(RIGHTS, r));
if (unknownRole || unknownRights.length) result.push({ kind, org: row.org, ...(kind === 'membership' ? { user: row.user } : { id: row.id }), ...(unknownRole ? { unknownRole } : {}), unknownRights });
}
return result;
}
// Check the host boundary before any read or lock. Every ancestor must be a real
// directory; configDir itself belongs to this uid and is not publicly writable.
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
let current = parse(configDir).root;
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part); const st = lstatSync(current);
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
}
const st = lstatSync(configDir);
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
}
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
assertAccountsOwner(dirname(file), { uid });
const doc = readJsonFile(file, 'accounts file', st => {
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
});
return checkAccounts(doc, context);
}
export const mintAccountId = () => `u-${randomBytes(6).toString('hex')}`;
const heldAccounts = new WeakMap();
// Low-level helper for callers already holding the earlier lifecycle/registry
// locks. A handle is valid only within this callback and serializes all writes.
export async function withAccountsLock(ctx, fn) {
assertAccountsOwner(ctx.configDir);
const lock = join(ctx.configDir, 'accounts.lock'); let fd;
for (let n = 0; n < 200; n++) {
try { fd = await fs.open(lock, 'wx', 0o600); break; }
catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); }
}
if (!fd) fail('accounts-locked', 'accounts lock is held; inspect it before retrying');
const handle = Object.freeze({}); heldAccounts.set(handle, { ctx, writing: false });
try { return await fn(handle); }
finally { heldAccounts.delete(handle); await fd.close(); await fs.unlink(lock); }
}
export async function withAccounts(ctx, options, fn) {
if (options.registry) await readRegistryLocked(options.registry, ctx);
return withAccountsLock(ctx, handle => withAccountsLocked(handle, options, fn));
}
const VERBS = new Set(['init', 'user.add', 'user.disable', 'user.enable', 'user.signout', 'user.remove', 'user.update', 'membership.add', 'membership.rights', 'membership.remove', 'membership.leave', 'invite.create', 'invite.accept', 'invite.decline', 'invite.revoke', 'org.delete', 'orphans.sweep']);
function generations(next, before, verb) {
for (const u of next.users) {
const old = before?.users.find(x => x.id === u.id);
if (!old) { u.authGen = 1; continue; }
const changed = u.state !== old.state || u.instanceRole !== old.instanceRole || !same(u.subjects, old.subjects);
if (changed) u.authGen = old.authGen + 1;
else if (u.authGen !== old.authGen && !(verb === 'user.signout' && u.authGen === old.authGen + 1)) bad('authGen may only advance one generation for signout or an authentication change');
if (u.created !== old.created) bad('account created time is immutable');
}
for (const m of next.memberships) {
const old = before?.memberships.find(x => x.org === m.org && x.incarnation === m.incarnation && x.user === m.user);
// A rollback row grants nothing, but must not block unrelated account
// revocation or the repair of another row. Preserve unchanged rows exactly.
if (old && same(m, old)) continue;
if (!Object.hasOwn(ROLE_RIGHTS, m.role) || m.role === 'owner') fail('unknown-role', 'unknown or ungrantable membership role');
m.rights = [...normalizeRights(m.rights)];
m.rev = old ? old.rev + (!same(old.rights, m.rights) || !same(old.scope, m.scope) ? 1 : 0) : 1;
}
for (const i of next.invites) {
const old = before?.invites.find(x => x.id === i.id);
if (old && same(i, old)) continue;
if (!Object.hasOwn(ROLE_RIGHTS, i.role) || i.role === 'owner') fail('unknown-role', 'unknown or ungrantable invite role');
i.rights = [...normalizeRights(i.rights)];
}
}
// Receipt construction has no free-form text: no names, subjects, bindings,
// tokens, hashes, caller-supplied objects, or cookies can enter the log.
function receipt(before, after, { verb, actor }, at) {
if (!VERBS.has(verb)) bad('unsupported receipt verb');
if (actor !== undefined && actor !== 'human') accountId(actor, 'receipt actor');
const membership = m => ({ org: m.org, incarnation: m.incarnation, user: m.user, role: Object.hasOwn(ROLE_RIGHTS, m.role) ? m.role : 'unknown', rights: m.rights, scope: m.scope, rev: m.rev });
return { at, uid: process.geteuid(), actor: actor ?? 'human', verb, revisionBefore: before?.revision ?? 0, revisionAfter: after.revision,
users: after.users.filter(u => !before || !same(u, before.users.find(x => x.id === u.id))).map(u => u.id),
userChanges: after.users.flatMap(u => {
const old = before?.users.find(x => x.id === u.id);
const changed = ['subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'].filter(key => !old || !same(u[key], old[key]));
return changed.length ? [{ id: u.id, changed }] : [];
}),
removedUsers: (before?.users ?? []).filter(u => !after.users.some(x => x.id === u.id)).map(u => u.id),
memberships: { before: (before?.memberships ?? []).map(membership), after: after.memberships.map(membership) } };
}
async function syncDirectory(dir) {
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { await fd.sync(); } finally { await fd.close(); }
}
// Re-read under the lock, compare expected revision, validate and atomically
// replace 0600 bytes, then append and fsync exactly one receipt while still held.
// A post-rename receipt failure reports committed=true: retrying is not safe.
export async function withAccountsLocked(handle, options, fn) {
const held = heldAccounts.get(handle);
if (!held || held.writing) fail('accounts-locked', 'a live, unused accounts lock handle is required');
held.writing = true;
const { ctx } = held; const file = join(ctx.configDir, 'accounts.json');
let temp, receiptFd, committed = false;
try {
if (options.registry) await readRegistryLocked(options.registry, ctx);
assertAccountsOwner(ctx.configDir);
let before;
if (options.init) {
try { await fs.lstat(file); fail('accounts-exists', 'accounts.json already exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
if (options.revision !== 0) fail('revision-conflict');
} else before = loadAccounts(file, ctx);
if (!Number.isSafeInteger(options.revision) || options.revision !== (before?.revision ?? 0)) fail('revision-conflict', `revision-conflict: accounts revision is ${before?.revision ?? 0}`);
const next = before ? structuredClone(before) : { accountsVersion: 1, revision: 0, users: [], memberships: [], invites: [] };
await fn(next);
generations(next, before, options.verb); next.revision = (before?.revision ?? 0) + 1;
const checked = checkAccounts(next, ctx);
const line = receipt(before, checked, options, new Date().toISOString());
// Refuse unsafe receipt destinations before publishing the account change.
receiptFd = await fs.open(join(ctx.configDir, 'accounts-receipts.jsonl'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
const st = await receiptFd.stat();
if (!st.isFile() || st.uid !== process.geteuid() || (st.mode & 0o022) || st.nlink !== 1) fail('accounts-unsafe', 'unsafe accounts receipt file');
temp = join(ctx.configDir, `.accounts-${randomBytes(12).toString('hex')}`);
const fd = await fs.open(temp, 'wx', 0o600);
try { await fd.writeFile(JSON.stringify(checked, null, 2) + '\n'); await fd.sync(); } finally { await fd.close(); }
await options.beforeReplace?.();
if (options.registry) await readRegistryLocked(options.registry, ctx);
// Defend against a non-cooperating writer even though cooperative writers
// already serialize through accounts.lock.
if (before) { if (!same(loadAccounts(file, ctx), before)) fail('revision-conflict', 'accounts changed before replace'); }
// Init must never replace a target, including one created by a writer that
// ignores accounts.lock. link publishes the complete inode or fails EEXIST.
if (before) await fs.rename(temp, file);
else await fs.link(temp, file);
committed = true;
if (!before) await fs.unlink(temp);
await (options.syncDirectory ?? syncDirectory)(ctx.configDir);
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
return checked;
} catch (e) { if (committed) e.committed = true; throw e; }
finally { if (temp) await fs.rm(temp, { force: true }); if (receiptFd) await receiptFd.close(); held.writing = false; }
}
+237
View File
@@ -0,0 +1,237 @@
// Agent levels (#1873 Phase A, slice A1; design docs/plans/2026-11-10_agent-levels-design.md).
// Agents are defined at three levels: the user's root (<configDir>/agents.json), the
// organization (`roles` in the organization file) and the project (`projects.<p>.agents` in the
// same file). A project's agents are their union, the most specific level winning. This module
// reads and resolves only: nothing here writes, launches or binds a credential, and nothing a
// root or project definition says reaches a bus.
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { loadRole } from "./role.mjs";
import { checkVars, mergeVars, REGISTRY } from "./vars.mjs";
import { deepFreeze, isPlainObject, readJsonFile, rejectUnknownKeys, requireDistinctList, requireId, requireObject } from "./util.mjs";
export const AGENT_LEVELS = Object.freeze(["root", "org", "project"]);
export const ROOT_FILE = "agents.json";
const CHIEF = "chief-of-staff";
const ASSISTANT = "assistant";
// The keys an org instance has that bind it to one organization. A root or project definition
// names a role and its vars only; the organization it runs in supplies the rest.
const BOUND_KEYS = ["credentials", "holder", "tracker", "identity", "publish", "hosts"];
export function rootAgentsPath(configDir) {
return join(configDir, ROOT_FILE);
}
function definitionOf(name, at, rolesDir, cache) {
requireId(name, `${at}.definition`);
if (!cache.has(name)) {
// A role refusal names host paths; this message travels to the Console, so it names none.
try { cache.set(name, loadRole(rolesDir, name)); }
catch (error) { if (error?.name !== "BusinessError") throw error; refuse(`${at}.definition: no usable role definition named ${name}`); }
}
const definition = cache.get(name);
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${name} is version 1 and can't back an agent`);
return definition;
}
function noBoundKeys(entry, at) {
for (const key of BOUND_KEYS) {
if (Object.hasOwn(entry, key)) refuse(`${at}.${key}: a ${at.startsWith("root") ? "root" : "project"} agent can't carry ${key}; the organization it runs in binds them`);
}
}
// A var value and every leaf it holds, as a var check's message quotes them (JSON.stringify).
function quoted(value, out = []) {
if (Array.isArray(value)) value.forEach((v) => quoted(v, out));
else if (isPlainObject(value)) Object.values(value).forEach((v) => quoted(v, out));
if (value !== undefined) out.push(JSON.stringify(value));
return out;
}
// checkVars, but a refusal never carries a value. Var checks quote the value they refuse, a value
// can be a secret pasted in the wrong place, and these refusals reach the Console. Each key is
// rechecked alone to name the one at fault; a message that quotes its value is replaced by one
// that names the key only. A rule across keys (the fallback set) names keys only already.
function checkVarsNamingKeys(vars, layer, where) {
try { return checkVars(vars, layer, where); }
catch (error) {
if (error?.name !== "BusinessError" || !isPlainObject(vars)) throw error;
for (const [key, value] of Object.entries(vars)) {
try { checkVars({ [key]: value }, layer, where); }
catch (one) {
if (one?.name !== "BusinessError") throw one;
if (quoted(value).some((text) => one.message.includes(text))) refuse(`${where}: variable ${key} has a value it can't take (the value is not shown)`);
throw one;
}
}
throw error;
}
}
// The root file's document. `where` names it in messages, never with a host path.
export function checkRootAgents(document, { rolesDir, where = "root agents" } = {}) {
requireObject(document, where);
rejectUnknownKeys(document, ["agentsVersion", "agents"], where);
if (document.agentsVersion !== 1) refuse(`${where} requires "agentsVersion": 1`);
requireObject(document.agents, `${where} agents`);
const cache = new Map();
const agents = {};
let assistant = null;
for (const [name, entry] of Object.entries(document.agents)) {
const at = `root agents.${name}`;
requireId(name, `${where} agent name`);
requireObject(entry, at);
noBoundKeys(entry, at);
rejectUnknownKeys(entry, ["definition", "vars", "excludeOrgs"], at);
const definition = definitionOf(entry.definition, at, rolesDir, cache);
// Each organization's chief is its own (REQ-COS-3): never a root agent.
if (entry.definition === CHIEF) refuse(`${at}: ${CHIEF} can't be a root agent; each organization has its own chief`);
if (entry.definition === ASSISTANT) {
if (name !== ASSISTANT) refuse(`${at}: the assistant definition is the root agent named assistant only`);
assistant = name;
}
agents[name] = {
definition: entry.definition,
title: definition.title,
vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`),
excludeOrgs: entry.excludeOrgs === undefined ? [] : requireDistinctList(entry.excludeOrgs, `${at}.excludeOrgs`, (id) => requireId(id, `${at}.excludeOrgs entry`)),
};
}
return deepFreeze({ agents, assistant });
}
// The root file. Missing means no root agents; anything else wrong refuses. The same descriptor
// checks as the organization file: no symbolic link, owned by this uid, not group or other writable.
export function loadRootAgents(configDir, { rolesDir } = {}) {
const file = rootAgentsPath(configDir);
let document;
try {
document = readJsonFile(file, "root agent file", (stat) => {
if (stat.uid !== process.getuid()) refuse(`root agent file must belong to uid ${process.getuid()}`);
if ((stat.mode & 0o022) !== 0) refuse(`root agent file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)})`);
});
} catch (error) {
if (error?.name !== "BusinessError") throw error;
if (error.exitCode === 4 && /not found/.test(error.message)) return deepFreeze({ state: "absent", agents: {}, assistant: null });
// The read's messages carry the host path; the Console shows this one, so it names the file only.
// A parse refusal also quotes the parser, which can quote the file's bytes: say only that it is not JSON.
if (/ is not valid JSON /.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode);
}
try { return deepFreeze({ state: "ok", ...checkRootAgents(document, { rolesDir }) }); }
catch (error) {
// An id check quotes what it was given; drop the quote here too, so nothing typed into the file
// reaches the Console through a refusal.
if (error?.name !== "BusinessError") throw error;
refuse(error.message.replace(/ \(got [\s\S]*\)$/, ""), error.exitCode);
}
}
const INTERSECT = Object.keys(REGISTRY).filter((key) => REGISTRY[key].merge === "intersect");
// One project's `agents` map. Each entry is exactly one of define, narrow or exclude.
export function checkProjectAgents(agents, { rolesDir, where }) {
requireObject(agents, where);
const cache = new Map();
const out = {};
for (const [name, entry] of Object.entries(agents)) {
const at = `${where}.${name}`;
requireId(name, `${where} agent name`);
requireObject(entry, at);
noBoundKeys(entry, at.replace(/^.*?projects\./, "project "));
rejectUnknownKeys(entry, ["definition", "vars", "exclude"], at);
if (Object.hasOwn(entry, "exclude")) {
if (entry.exclude !== true || Object.keys(entry).length !== 1) refuse(`${at}: an exclude entry is exactly {"exclude": true}`);
out[name] = { form: "exclude" };
continue;
}
if (Object.hasOwn(entry, "definition")) {
const definition = definitionOf(entry.definition, at, rolesDir, cache);
if (entry.definition === CHIEF || entry.definition === ASSISTANT) refuse(`${at}: ${entry.definition} can't be a project agent`);
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`) };
continue;
}
if (!isPlainObject(entry.vars) || Object.keys(entry.vars).length === 0) refuse(`${at}: an entry defines (definition), narrows (vars) or excludes (exclude)`);
// A project narrows an inherited agent; it never widens one, so only intersect keys.
for (const key of Object.keys(entry.vars)) {
if (!INTERSECT.includes(key)) refuse(`${at}.vars: a project can only narrow ${INTERSECT.join(", ")}, not ${JSON.stringify(key)}`);
}
out[name] = { form: "narrow", vars: checkVarsNamingKeys(entry.vars, "project", `${at}.vars`) };
}
return deepFreeze(out);
}
// The organization's levels from its raw document (already validated by the organization
// loader): its own instances and each project's entries. Library projects take no agents.
function orgLevels(doc, rolesDir) {
const org = {};
for (const [name, entry] of Object.entries(doc?.roles ?? {})) {
org[name] = { definition: entry.definition, vars: entry.vars ?? {} };
}
const projects = {};
for (const [id, entry] of Object.entries(doc?.projects ?? {})) {
if (entry?.kind === "library") continue;
projects[id] = entry?.agents === undefined ? {} : checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents` });
}
return { org, projects };
}
// The agents available in project `project` of organization `orgId`: the union of the root
// agents (minus those excluded for this organization), the organization's instances and the
// project's entries. Refuses when a project entry names nothing it could act on, or touches the
// chief or the assistant.
export function projectUnion({ orgId, root, org, project, projectId }) {
requireId(orgId, "organization id");
const agents = new Map();
const excluded = [];
for (const [name, entry] of Object.entries(root.agents)) {
if (entry.definition === ASSISTANT) continue; // the root assistant is not one organization's
if (entry.excludeOrgs.includes(orgId)) { excluded.push({ name, level: "root", by: "root" }); continue; }
agents.set(name, { name, definition: entry.definition, level: "root", vars: entry.vars, overrides: [], narrowed: [] });
}
for (const [name, entry] of Object.entries(org)) {
const shadowed = agents.get(name);
agents.set(name, { name, definition: entry.definition, level: "org", vars: entry.vars, overrides: shadowed ? [{ level: shadowed.level, definition: shadowed.definition }] : [], narrowed: [] });
}
if (projectId !== undefined) {
for (const [name, entry] of Object.entries(project ?? {})) {
const inherited = agents.get(name);
const at = `projects.${projectId}.agents.${name}`;
if (inherited && (inherited.definition === CHIEF || inherited.definition === ASSISTANT)) refuse(`${at}: a project can't change the organization's ${inherited.definition}`);
if (entry.form === "define") {
agents.set(name, { name, definition: entry.definition, level: "project", project: projectId, vars: entry.vars,
overrides: inherited ? [...inherited.overrides, { level: inherited.level, definition: inherited.definition }] : [], narrowed: [] });
continue;
}
if (!inherited) refuse(`${at}: names no root or organization agent to ${entry.form}`);
if (entry.form === "exclude") { agents.delete(name); excluded.push({ name, level: inherited.level, by: "project" }); continue; }
// mergeVars applies the intersect rule; its registry defaults are not this agent's, so only
// the keys either side set are kept.
const merged = mergeVars([{ source: inherited.level, vars: inherited.vars }, { source: "project", vars: entry.vars }]).vars;
const keys = new Set([...Object.keys(inherited.vars), ...Object.keys(entry.vars)]);
const vars = Object.fromEntries(Object.entries(merged).filter(([key]) => keys.has(key)));
agents.set(name, { ...inherited, vars, narrowed: Object.keys(entry.vars) });
}
}
// Every agent in a union works inside this organization, an organization's own `assistant`
// instance included. Only the root assistant, which no union holds, works across organizations.
const list = [...agents.values()].map((a) => ({ ...a, scope: { kind: "org", org: orgId } }));
const order = { root: 0, org: 1, project: 2 };
list.sort((a, b) => order[a.level] - order[b.level] || a.name.localeCompare(b.name));
// A root agent turned off here, or excluded by the project, whose name a more specific level then
// defines is available under that definition, so it is not reported as excluded.
return deepFreeze({ org: orgId, project: projectId ?? null, agents: list, excluded: excluded.filter((e) => !agents.has(e.name)) });
}
// Every level for one organization, read from its validated document: the org instances, each
// project's raw entries and each project's union. One project that refuses refuses only itself.
export function resolveAgentLevels({ orgId, doc, root, rolesDir }) {
const { org, projects } = orgLevels(doc, rolesDir);
const unions = {};
for (const [projectId, entries] of Object.entries(projects)) {
try { unions[projectId] = { ok: true, ...projectUnion({ orgId, root, org, project: entries, projectId }) }; }
catch (error) { if (error?.name !== "BusinessError") throw error; unions[projectId] = { ok: false, reason: error.message }; }
}
return deepFreeze({ org: orgId, base: projectUnion({ orgId, root, org }), projects, unions });
}
+3
View File
@@ -19,3 +19,6 @@ export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./acces
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
export { loadSeatSets } from "./library-seats.mjs";
export {
AGENT_LEVELS, rootAgentsPath, checkRootAgents, loadRootAgents, checkProjectAgents, projectUnion, resolveAgentLevels,
} from "./agent-levels.mjs";
+17 -2
View File
@@ -36,14 +36,29 @@ export async function readRegistry({ configDir }) {
return validateRegistry(doc, file);
}
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
const heldRegistries = new WeakMap();
// Only a live withRegistry document may be reused by a later accounts-lock
// holder. Recheck the persisted snapshot without reacquiring this lock.
export async function readRegistryLocked(registry, ctx) {
const held = heldRegistries.get(registry);
if (!held) fail('org-locked', 'a live registry lock document is required');
if (ctx && (ctx.configDir !== held.ctx.configDir || ctx.instance !== held.ctx.instance)) fail('org-mismatch', 'registry lock belongs to another instance');
const current = await readRegistry(held.ctx);
if (JSON.stringify(current) !== held.persisted) fail('registry-changed', 'registry-changed before accounts replace');
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
}
export async function withRegistry(ctx, fn, { write = true } = {}) {
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
if (!fd) fail('org-locked', 'registry lock is held', lock);
try {
const registry = await readRegistry(ctx) ?? { version: 1, instance: ctx.instance, orgs: {} };
const persisted = await readRegistry(ctx);
const registry = persisted ?? { version: 1, instance: ctx.instance, orgs: {} };
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
const result = await fn(registry);
heldRegistries.set(registry, { ctx, persisted: JSON.stringify(persisted) });
let result;
try { result = await fn(registry); }
finally { heldRegistries.delete(registry); }
if (write) {
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
+129
View File
@@ -0,0 +1,129 @@
// Accounts design draft 3, section 3 / M1 (#1873). Pure org policy only:
// these tables do not grant access, resolve ownership, or inspect a caller.
const record = entries => Object.freeze(Object.assign(Object.create(null), entries));
const flag = grantable => Object.freeze({ grantable });
export const RIGHTS = record({
'org.view': flag(true),
'thread.read': flag(true),
'thread.write': flag(true),
'board.read': flag(true),
'task.write': flag(true),
decide: flag(true),
'request.write': flag(false),
'members.manage': flag(false),
'org.setup': flag(false),
'org.lifecycle': flag(false),
});
// Templates are expanded into stored rights, never evaluated as live roles.
// Only owner.json confers ownership; the owner bundle is never grantable.
export const ROLE_RIGHTS = record({
owner: Object.freeze(Object.keys(RIGHTS).sort()),
member: Object.freeze(['board.read', 'org.view', 'thread.read']),
collaborator: Object.freeze(['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write']),
approver: Object.freeze(['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write']),
});
// A reserved right still names the owner's operation. Null marks an explicit
// owner-only operation with no member right. An unknown verb yields undefined.
// Null-prototype records keep inherited property names from becoming rights.
// M5 must separately exclude credential.mint from decide and assignees from
// member task writes; these names alone cannot express action/field restrictions.
export const VERB_RIGHTS = record({
'project.list': 'org.view',
names: 'org.view',
'thread.list': 'thread.read',
'thread.messages': 'thread.read',
plugins: 'thread.read',
'thread.open': 'thread.write',
'thread.create': 'thread.write',
'thread.send': 'thread.write',
'thread.share': 'thread.write',
'thread.unshare': 'thread.write',
'thread.shares': 'thread.write',
inbox: 'board.read',
agents: 'board.read',
'thread.claims': 'board.read',
tasks: 'board.read',
decisions: 'board.read',
usage: 'board.read',
limits: 'board.read',
'request.get.console': 'board.read',
'request.projects.console': 'board.read',
'request.artifact.console': 'board.read',
'request.workspace.console': 'board.read',
trail: null,
credentials: null,
replies: null,
messages: null,
state: null,
'tasks.create': 'task.write',
'tasks.edit': 'task.write',
'tasks.archive': 'task.write',
'tasks.unarchive': 'task.write',
'tasks.delete': null,
'decision.preview.console': 'decide',
'decision.resolve.console': 'decide',
'request.answer.console': 'request.write',
'request.attach.console': 'request.write',
'request.cancel.console': 'request.write',
'request.mode.console': 'request.write',
'request.draft.console': 'request.write',
'prd.revoke.console': 'request.write',
'plugins.set': 'org.setup',
'system-types.console': 'org.setup',
'project.remote.get.console': 'org.setup',
'project.remote.set.console': 'org.setup',
});
function refuse(code) {
const error = new Error(code);
error.code = code;
throw error;
}
const known = right => typeof right === 'string' && Object.hasOwn(RIGHTS, right);
// Only thread writes need the collaborator scope level. Unknown rights must
// refuse before the viewer fallback can run.
export function rightLevel(right) {
if (!known(right)) refuse('unknown-right');
return right === 'thread.write' ? 'collaborator' : 'viewer';
}
function memberRights(rights) {
if (!Array.isArray(rights)) refuse('invalid-rights');
for (const right of rights) {
if (right === 'owner') refuse('right-not-grantable');
if (!known(right)) refuse('unknown-right');
if (!RIGHTS[right].grantable) refuse('right-not-grantable');
}
}
// Normalize a membership write; never mutate its current rights or the role
// template. Explicit removal of the compulsory org.view right always refuses.
// When add and remove overlap, removal wins (except org.view, which refuses).
export function normalizeRights(rights, options = {}) {
if (options === null || typeof options !== 'object' || Array.isArray(options) ||
Object.keys(options).some(key => !['add', 'remove'].includes(key))) refuse('invalid-rights');
const add = Object.hasOwn(options, 'add') ? options.add : [];
const remove = Object.hasOwn(options, 'remove') ? options.remove : [];
memberRights(rights);
memberRights(add);
memberRights(remove);
if (remove.includes('org.view')) refuse('right-not-removable');
const result = new Set(['org.view', ...rights, ...add]);
for (const right of remove) result.delete(right);
return Object.freeze([...result].sort());
}
// Subset check only: the caller must independently establish the fresh owner's
// members.manage authority. A malformed or unknown granter set grants nothing.
export function canGrant(granterRights, rights) {
if (!Array.isArray(granterRights) || !Array.isArray(rights)) return false;
for (const right of granterRights) if (!known(right)) return false;
for (const right of rights) {
if (!known(right) || !RIGHTS[right].grantable || !granterRights.includes(right)) return false;
}
return true;
}
@@ -12,9 +12,11 @@ import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { END_PROTECTED } from "./vocabulary.mjs";
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
import { hasModule } from "../../plugins/src/loader.mjs";
import { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { checkAppearance } from "./appearance.mjs";
import { checkProjectAgents } from "./agent-levels.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
@@ -278,7 +280,10 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
continue;
}
rejectUnknownKeys(entry, ["root", "repo", "remote", "git"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote", "git", "agents"], `${file} projects.${id}`);
// #1873 A1: the project's own agent entries. Checked so a bad entry refuses the whole file, but
// not copied below: nothing a bus loads sees them; only the Console's agent-levels read does.
if (Object.hasOwn(entry, "agents")) checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents` });
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
@@ -322,6 +327,9 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
// here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
for (const id of plugins["system-type"] ?? []) {
if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
}
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
const appearance = checkAppearance(document.appearance, `${file} appearance`);
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
@@ -105,6 +105,13 @@ function usageFence(old, document, actor) {
fail("organization-usage-human-only");
}
function systemTypesFence(old, document, actor) {
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
if (actor === old.human && actor !== 'tester' && !agents.includes(actor)) return;
fail('organization-system-types-human-only');
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
// the business's human as the file stood before the write, and no agent or the tester under that
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
@@ -286,6 +293,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
replyLimitsFence(old, document, actor);
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
systemTypesFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
@@ -295,6 +303,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
replyLimitsFence(old, written, actor);
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
systemTypesFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
if (beforeWrite) {
beforeWrite();
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
import { templateRender } from '../src/org-service.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { REPO_ROLES } from './helpers.mjs';
import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs';
export const OP = 'u-000000000001', USER = 'u-000000000002';
export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] };
export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' });
export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] });
function setup(t, doc = document()) {
const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
return { configDir: dir, instance: 'test', ...context, file };
}
const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn);
test('loader refuses every malformed document as a whole, including nested unknown keys', t => {
const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }];
for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] }));
const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/);
for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); }
chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/);
});
test('unknown role and right load as repair rows; account may be a business human', () => {
const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m);
const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } });
assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0]));
assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]);
});
test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 9, () => {}), /revision-conflict/);
for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) {
await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); }));
}
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false);
});
test('rights/scope revisions and security generations are maintained by the writer', async t => {
const d = document(); d.memberships.push(member()); const s = setup(t, d);
let rev = 1;
for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) {
const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev);
}
let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev);
result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2);
result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3);
assert.equal(result.users[1].authGen, 6);
const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); }
assert.equal(statSync(s.file).mode & 0o777, 0o600);
});
test('concurrent writes serialize and the later stale write changes nothing', async t => {
const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r);
const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready;
const second = write(s, 1, d => { d.users[1].authGen++; }); release();
const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/);
assert.equal(loadAccounts(s.file, s).revision, 2);
});
test('locked helpers require live handles; registry dependency is rechecked before replace', async t => {
const s = setup(t); let held;
await assert.rejects(readRegistryLocked({}), /lock/);
await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/);
await withRegistry(s, async registry => {
assert.equal(await readRegistryLocked(registry), registry);
await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); });
});
await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/);
const before = readFileSync(s.file, 'utf8');
await withRegistry(s, async registry => {
await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/);
}, { write: false });
assert.equal(readFileSync(s.file, 'utf8'), before);
});
test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/);
assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => {
const s = setup(t); const secret = 'subject-secret';
await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret'));
await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' }));
await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' }));
});
test('malformed membership rows refuse all of the file; reserved rights never load', () => {
const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad'];
for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); }
});
function invitation() {
const { user: ignored, rev, added, via, ...row } = member();
return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: '[email protected]' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' };
}
test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => {
const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1);
for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) {
const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context));
}
d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite');
});
test('unsafe receipt destination refuses before publication and leaves its target intact', async t => {
const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl'));
const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched');
});
test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => {
const s = setup(t), other = setup(t);
await withAccountsLock(s, async handle => {
await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++);
await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++);
});
await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); });
assert.equal(loadAccounts(s.file, s).revision, 3);
});
test('invite token hashes and subject bindings never reach receipts', async t => {
const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' };
await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create');
});
test('a failed rename preserves published bytes and appends no receipt', async t => {
const s = setup(t), before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' });
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock')));
});
test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => {
const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d);
const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' });
assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites);
const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2);
await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/);
await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/);
const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites);
});
test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => {
for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) {
const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control;
assert.throws(() => checkAccounts(d, context));
}
});
test('review P3-2: invite validity is between one and 168 hours inclusive', () => {
for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); }
for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); }
});
test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => {
const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator');
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]);
});
test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => {
const s = setup(t); let error;
try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; }
assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-5: init cannot clobber a file created after its last absence check', async t => {
const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file';
await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' });
assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-6: a real business file and accounts file load with the same human id', async t => {
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
});
@@ -0,0 +1,194 @@
// #1873 Phase A slice A1: the three agent levels and their union (design
// docs/plans/2026-11-10_agent-levels-design.md sections 2, 3 and 6). Read-only: nothing here writes.
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, resolveAgentLevels, validateOrganizationDocument,
} from "../src/index.mjs";
import { businessDoc, REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
const rolesDir = REPO_ROLES;
const NONE = Object.freeze({ state: "absent", agents: {}, assistant: null });
const root = (agents) => checkRootAgents({ agentsVersion: 1, agents }, { rolesDir });
const project = (agents) => checkProjectAgents(agents, { rolesDir, where: "projects.web.agents" });
const ORG = { pm: { definition: "pm", vars: {} }, reviewer: { definition: "reviewer", vars: {} }, chief: { definition: "chief-of-staff", vars: {} } };
const names = (union) => union.agents.map((a) => `${a.level}:${a.name}`);
const refuses = (fn, pattern) => assert.throws(fn, (error) => error.name === "BusinessError" && pattern.test(error.message));
test("root level alone: each root agent runs in the organization it is used from", () => {
const r = root({ researcher: { definition: "research", vars: { model: "claude-opus-5-5" } }, assistant: { definition: "assistant" } });
assert.equal(r.assistant, "assistant");
const union = projectUnion({ orgId: "acme", root: r, org: {}, project: {}, projectId: "web" });
assert.deepEqual(names(union), ["root:researcher"]);
assert.deepEqual(union.agents[0].scope, { kind: "org", org: "acme" });
assert.equal(union.agents[0].vars.model, "claude-opus-5-5");
// The same root agent used from another organization is scoped to that one, never to both.
assert.deepEqual(projectUnion({ orgId: "home", root: r, org: {} }).agents[0].scope, { kind: "org", org: "home" });
});
test("organization level alone: its instances, scoped to it", () => {
const union = projectUnion({ orgId: "acme", root: NONE, org: ORG });
assert.deepEqual(names(union), ["org:chief", "org:pm", "org:reviewer"]);
assert.ok(union.agents.every((a) => a.scope.org === "acme"));
assert.equal(union.project, null);
});
test("project level alone: a project-defined agent", () => {
const union = projectUnion({ orgId: "acme", root: NONE, org: {}, project: project({ checker: { definition: "fact-checker" } }), projectId: "web" });
assert.deepEqual(names(union), ["project:checker"]);
assert.equal(union.agents[0].project, "web");
assert.equal(union.agents[0].definition, "fact-checker");
});
test("clash: the most specific level wins and the shadowed definitions are recorded", () => {
const r = root({ reviewer: { definition: "reviewer", vars: { model: "glm-5.3-flash" } } });
const p = project({ reviewer: { definition: "security-reviewer" } });
const union = projectUnion({ orgId: "acme", root: r, org: ORG, project: p, projectId: "web" });
const reviewer = union.agents.find((a) => a.name === "reviewer");
assert.equal(reviewer.level, "project");
assert.equal(reviewer.definition, "security-reviewer");
assert.deepEqual(reviewer.overrides, [{ level: "root", definition: "reviewer" }, { level: "org", definition: "reviewer" }]);
// The whole entry wins: the root's model does not leak into the project definition.
assert.equal(reviewer.vars.model, undefined);
assert.equal(union.agents.filter((a) => a.name === "reviewer").length, 1);
});
test("project narrowing: intersect keys only, never wider than what it inherits", () => {
const org = { ...ORG, coder: { definition: "coder", vars: { model: "claude-sonnet-5-5", "limits.network": "api-only", "limits.tools": ["read", "bash"] } } };
const p = project({ coder: { vars: { "limits.network": "open", "limits.tools": ["read", "edit"] } } });
const coder = projectUnion({ orgId: "acme", root: NONE, org, project: p, projectId: "web" }).agents.find((a) => a.name === "coder");
assert.equal(coder.level, "org");
assert.equal(coder.vars["limits.network"], "api-only"); // asking for open stays at the narrower api-only
assert.deepEqual(coder.vars["limits.tools"], ["read"]);
assert.equal(coder.vars.model, "claude-sonnet-5-5");
assert.deepEqual(coder.narrowed, ["limits.network", "limits.tools"]);
assert.equal(coder.vars["tracker.kind"], undefined); // no registry defaults leak in
refuses(() => project({ coder: { vars: { model: "claude-opus-5-5" } } }), /can only narrow limits\.tools, limits\.network, limits\.authority, not "model"/);
});
test("exclusions: a root opt-out per organization and a project exclude", () => {
const r = root({ reviewer: { definition: "reviewer", excludeOrgs: ["home"] }, researcher: { definition: "research" } });
assert.deepEqual(names(projectUnion({ orgId: "home", root: r, org: {} })), ["root:researcher"]);
assert.deepEqual(projectUnion({ orgId: "home", root: r, org: {} }).excluded, [{ name: "reviewer", level: "root", by: "root" }]);
const union = projectUnion({ orgId: "acme", root: r, org: {}, project: project({ researcher: { exclude: true } }), projectId: "web" });
assert.deepEqual(names(union), ["root:reviewer"]);
assert.deepEqual(union.excluded, [{ name: "researcher", level: "root", by: "project" }]);
});
test("a name the root turned off, or a project excluded, that a more specific level defines is available, not excluded", () => {
const r = root({ reviewer: { definition: "reviewer", excludeOrgs: ["home"] } });
// The organization's own reviewer: available under the organization's definition, no opt-out listed.
const home = projectUnion({ orgId: "home", root: r, org: { reviewer: { definition: "reviewer", vars: {} } } });
assert.deepEqual(names(home), ["org:reviewer"]);
assert.deepEqual(home.excluded, []);
// A project that defines the name the root turned off for its organization.
const web = projectUnion({ orgId: "home", root: r, org: {}, project: project({ reviewer: { definition: "security-reviewer" } }), projectId: "web" });
assert.deepEqual(names(web), ["project:reviewer"]);
assert.deepEqual(web.excluded, []);
// Without a definition of its own, the opt-out is still reported.
assert.deepEqual(projectUnion({ orgId: "home", root: r, org: {}, project: {}, projectId: "web" }).excluded, [{ name: "reviewer", level: "root", by: "root" }]);
});
test("the root assistant is cross-organization and never in one organization's union", () => {
const r = root({ assistant: { definition: "assistant" } });
assert.deepEqual(projectUnion({ orgId: "acme", root: r, org: {} }).agents, []);
// An organization's own assistant instance works inside that organization only.
const own = projectUnion({ orgId: "acme", root: r, org: { assistant: { definition: "assistant", vars: {} } } });
assert.deepEqual(names(own), ["org:assistant"]);
assert.deepEqual(own.agents[0].scope, { kind: "org", org: "acme" });
refuses(() => root({ helper: { definition: "assistant" } }), /assistant definition is the root agent named assistant only/);
});
test("refusals in a project's union: unknown targets, the chief, the assistant", () => {
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ ghost: { exclude: true } }), projectId: "web" }), /names no root or organization agent to exclude/);
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ ghost: { vars: { "limits.network": "none" } } }), projectId: "web" }), /to narrow/);
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ chief: { exclude: true } }), projectId: "web" }), /can't change the organization's chief-of-staff/);
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ chief: { definition: "reviewer" } }), projectId: "web" }), /can't change the organization's chief-of-staff/);
});
test("malformed root definitions refuse", () => {
refuses(() => checkRootAgents({ agentsVersion: 2, agents: {} }, { rolesDir }), /agentsVersion/);
refuses(() => checkRootAgents({ agentsVersion: 1, agents: {}, extra: 1 }, { rolesDir }), /unsupported root agents key: "extra"/);
refuses(() => root({ boss: { definition: "chief-of-staff" } }), /can't be a root agent/);
refuses(() => root({ old: { definition: "researcher" } }), /version 1/);
refuses(() => root({ x: { definition: "no-such-role" } }), /no usable role definition named no-such-role/);
refuses(() => root({ x: { definition: "reviewer", credentials: {} } }), /can't carry credentials/);
refuses(() => root({ x: { definition: "reviewer", vars: { "tracker.project": 3 } } }), /can't be set at the agent layer/);
refuses(() => root({ x: { definition: "reviewer", excludeOrgs: ["a", "a"] } }), /duplicate entry/);
refuses(() => root({ "Bad Name": { definition: "reviewer" } }), /agent name must match/);
});
test("a refused var names its key, never its value", () => {
const secret = "sk-secret-marker-7731";
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key} has a value it can't take`) && !error.message.includes(secret));
hides(() => root({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
hides(() => root({ x: { definition: "reviewer", vars: { model: "opus-5.5", harness: `${secret}!` } } }), "harness");
hides(() => root({ x: { definition: "reviewer", vars: { "limits.tools": ["read", secret] } } }), "limits.tools");
hides(() => project({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
hides(() => project({ x: { vars: { "limits.network": secret } } }), "limits.network");
// A message that quotes no value is kept whole, so the owner still reads the rule.
refuses(() => root({ x: { definition: "reviewer", vars: { "tracker.project": 3 } } }), /variable tracker\.project can't be set at the agent layer/);
refuses(() => root({ x: { definition: "reviewer", vars: { "fallback.model": "m" } } }), /a fallback sets all of/);
});
test("malformed project definitions refuse", () => {
refuses(() => project({ x: { exclude: true, vars: {} } }), /exactly \{"exclude": true\}/);
refuses(() => project({ x: { exclude: false } }), /exactly/);
refuses(() => project({ x: {} }), /defines \(definition\), narrows \(vars\) or excludes/);
refuses(() => project({ x: { definition: "chief-of-staff" } }), /can't be a project agent/);
refuses(() => project({ x: { definition: "assistant" } }), /can't be a project agent/);
refuses(() => project({ x: { definition: "reviewer", hosts: [] } }), /can't carry hosts/);
refuses(() => project({ x: { definition: "reviewer", other: 1 } }), /unsupported projects\.web\.agents\.x key/);
});
test("root file: absent, owner and mode checks, symbolic link, bad JSON, and no host path in a message", () => {
const dir = tmp();
assert.equal(loadRootAgents(dir, { rolesDir }).state, "absent");
const file = writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { reviewer: { definition: "reviewer" } } });
const ok = loadRootAgents(dir, { rolesDir });
assert.equal(ok.state, "ok");
assert.deepEqual(Object.keys(ok.agents), ["reviewer"]);
chmodSync(file, 0o620);
refuses(() => loadRootAgents(dir, { rolesDir }), /writable by group or other/);
chmodSync(file, 0o600);
writeFileSync(file, "{ nope");
assert.throws(() => loadRootAgents(dir, { rolesDir }), (error) => /not valid JSON \(agents\.json\)/.test(error.message) && !error.message.includes(dir));
// The parser's own text can quote the file's bytes; the refusal never carries it.
writeFileSync(file, '{"agentsVersion": 1, "agents": {"x": secret-marker-7731}}');
assert.throws(() => loadRootAgents(dir, { rolesDir }), (error) => error.message === "root agent file is not valid JSON (agents.json)" && error.exitCode !== undefined);
// An id check's quote of what it was given is dropped too.
writeFileSync(file, JSON.stringify({ agentsVersion: 1, agents: { x: { definition: "Secret Marker 7731" } } }));
assert.throws(() => loadRootAgents(dir, { rolesDir }), (error) => /root agents\.x\.definition must match/.test(error.message) && !error.message.includes("Marker"));
const linked = tmp();
symlinkSync(file, join(linked, "agents.json"));
assert.throws(() => loadRootAgents(linked, { rolesDir }), (error) => error.exitCode === 4 && !error.message.includes(linked));
});
test("organization file: project agents are checked but not loaded; a malformed one refuses the file", () => {
const dir = tmp();
const doc = businessDoc(dir);
doc.projects.stack.agents = { coder: { vars: { "limits.network": "none" } }, checker: { definition: "fact-checker" } };
const loaded = validateOrganizationDocument(structuredClone(doc), "acme.json", { rolesDir });
assert.equal(loaded.projects.stack.agents, undefined);
const levels = resolveAgentLevels({ orgId: "acme", doc, root: NONE, rolesDir });
assert.equal(levels.unions.stack.ok, true);
assert.ok(levels.unions.stack.agents.some((a) => a.name === "checker" && a.level === "project"));
assert.deepEqual(levels.unions.stack.agents.find((a) => a.name === "coder").narrowed, ["limits.network"]);
doc.projects.stack.agents = { boss: { definition: "chief-of-staff" } };
refuses(() => validateOrganizationDocument(doc, "acme.json", { rolesDir }), /can't be a project agent/);
});
test("one project's bad union refuses only that project", () => {
const dir = tmp();
const doc = businessDoc(dir);
mkdirSync(join(dir, "other"));
doc.projects.other = { root: join(dir, "other"), agents: { ghost: { exclude: true } } };
const levels = resolveAgentLevels({ orgId: "acme", doc, root: NONE, rolesDir });
assert.equal(levels.unions.stack.ok, true);
assert.equal(levels.unions.other.ok, false);
assert.match(levels.unions.other.reason, /names no root or organization agent to exclude/);
assert.ok(levels.base.agents.length > 0);
});
@@ -0,0 +1,208 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as policy from '../src/org-rights.mjs';
import { CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS } from '../../bus/src/broker.mjs';
// Accounts design draft 3, section 3 and M1 (#1873). Expected assignments are
// written out: adding a real bus verb or widening a bundle must change this pin.
const RIGHTS = {
'org.view': { grantable: true },
'thread.read': { grantable: true },
'thread.write': { grantable: true },
'board.read': { grantable: true },
'task.write': { grantable: true },
decide: { grantable: true },
'request.write': { grantable: false },
'members.manage': { grantable: false },
'org.setup': { grantable: false },
'org.lifecycle': { grantable: false },
};
const BUNDLES = {
owner: ['board.read', 'decide', 'members.manage', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'],
member: ['board.read', 'org.view', 'thread.read'],
collaborator: ['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write'],
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
};
const VERBS = {
'project.list': 'org.view',
names: 'org.view',
'thread.list': 'thread.read',
'thread.messages': 'thread.read',
plugins: 'thread.read',
'thread.open': 'thread.write',
'thread.create': 'thread.write',
'thread.send': 'thread.write',
'thread.share': 'thread.write',
'thread.unshare': 'thread.write',
'thread.shares': 'thread.write',
inbox: 'board.read',
agents: 'board.read',
'thread.claims': 'board.read',
tasks: 'board.read',
decisions: 'board.read',
usage: 'board.read',
limits: 'board.read',
'request.get.console': 'board.read',
'request.projects.console': 'board.read',
'request.artifact.console': 'board.read',
'request.workspace.console': 'board.read',
trail: null,
credentials: null,
replies: null,
messages: null,
state: null,
'tasks.create': 'task.write',
'tasks.edit': 'task.write',
'tasks.archive': 'task.write',
'tasks.unarchive': 'task.write',
'tasks.delete': null,
'decision.preview.console': 'decide',
'decision.resolve.console': 'decide',
'request.answer.console': 'request.write',
'request.attach.console': 'request.write',
'request.cancel.console': 'request.write',
'request.mode.console': 'request.write',
'request.draft.console': 'request.write',
'prd.revoke.console': 'request.write',
'plugins.set': 'org.setup',
'system-types.console': 'org.setup',
'project.remote.get.console': 'org.setup',
'project.remote.set.console': 'org.setup',
};
test('M1 the ten rights pin the grantable flags, including compulsory org.view', () => {
assert.deepEqual({ ...policy.RIGHTS }, RIGHTS);
});
for (const [role, rights] of Object.entries(BUNDLES)) {
test(`M1 ${role} has exactly its reviewed rights, expanded and sorted`, () => {
assert.deepEqual(policy.ROLE_RIGHTS[role], rights);
});
}
test('M1 owner holds every right; only owner holds any reserved right', () => {
assert.deepEqual(policy.ROLE_RIGHTS.owner, Object.keys(policy.RIGHTS).sort());
for (const role of ['member', 'collaborator', 'approver']) {
for (const right of policy.ROLE_RIGHTS[role]) assert.equal(policy.RIGHTS[right].grantable, true, `${role}: ${right}`);
assert.ok(!policy.ROLE_RIGHTS[role].includes('request.write'));
}
assert.ok(!policy.ROLE_RIGHTS.member.includes('decide'));
assert.ok(!policy.ROLE_RIGHTS.member.includes('task.write'));
});
test('M1 canGrant allows only known grantable rights held by the granter', () => {
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, policy.ROLE_RIGHTS.approver), true);
assert.equal(policy.canGrant(['org.view', 'decide'], ['decide']), true);
assert.equal(policy.canGrant(['org.view'], ['thread.read']), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, policy.ROLE_RIGHTS.collaborator), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, []), true);
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, [right]), false, right);
}
assert.equal(policy.canGrant(['org.view', 'future.right'], ['org.view']), false);
});
test('M1 canGrant refuses malformed lists and values, including sparse arrays', () => {
for (const value of [undefined, null, 'owner', {}, 1, [null], [1], [new String('org.view')], Array(1)]) {
assert.equal(policy.canGrant(value, ['org.view']), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, value), false);
}
});
test('M1 normalizeRights sorts, deduplicates, adds org.view and applies explicit additions and removals', () => {
const rights = Object.freeze(['thread.read', 'board.read', 'thread.read']);
assert.deepEqual(policy.normalizeRights(rights), ['board.read', 'org.view', 'thread.read']);
assert.deepEqual(policy.normalizeRights([]), ['org.view']);
const add = Object.freeze(['task.write', 'decide', 'task.write']);
const remove = Object.freeze(['thread.read']);
assert.deepEqual(policy.normalizeRights(rights, { add, remove }), ['board.read', 'decide', 'org.view', 'task.write']);
assert.deepEqual(rights, ['thread.read', 'board.read', 'thread.read']);
assert.notEqual(policy.normalizeRights(rights), rights);
});
test('M1 normalization refuses any attempt to remove org.view, even when re-added', () => {
for (const rights of [[], ['org.view']]) {
assert.throws(() => policy.normalizeRights(rights, { add: ['org.view'], remove: ['org.view'] }), { code: 'right-not-removable' });
}
});
test('M1 normalization refuses reserved, owner and unknown rights in every input before returning a result', () => {
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
const code = ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle'].includes(right) ? 'right-not-grantable' : 'unknown-right';
for (const [rights, options] of [[[right], {}], [[], { add: [right] }], [[], { remove: [right] }]]) {
assert.throws(() => policy.normalizeRights(rights, options), { code }, right);
}
}
});
test('M1 normalization refuses malformed lists, options and non-string rights', () => {
for (const value of [undefined, null, {}, 'member', 0, [null], [42], [new String('org.view')], Array(1)]) {
assert.throws(() => policy.normalizeRights(value));
assert.throws(() => policy.normalizeRights([], { add: value }));
assert.throws(() => policy.normalizeRights([], { remove: value }));
}
for (const options of [null, [], 'member', 1, { typo: [] }]) {
assert.throws(() => policy.normalizeRights([], options), { code: 'invalid-rights' });
}
});
test('M1 VERB_RIGHTS pins every assignment from section 3, with explicit null for unmapped owner-only operations', () => {
assert.deepEqual({ ...policy.VERB_RIGHTS }, VERBS);
for (const right of Object.values(policy.VERB_RIGHTS)) assert.ok(right === null || Object.hasOwn(policy.RIGHTS, right));
});
test('M1 every actual console, user, board and person-task verb has a reviewed assignment', () => {
for (const verbs of [CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS]) {
for (const verb of verbs) {
assert.ok(Object.hasOwn(VERBS, verb), `${verb} needs a reviewed assignment`);
assert.ok(Object.hasOwn(policy.VERB_RIGHTS, verb), `${verb} missing from VERB_RIGHTS`);
assert.equal(policy.VERB_RIGHTS[verb], VERBS[verb], verb);
}
}
assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([
...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits',
])].sort());
});
test('M1 sensitive board reads, task delete, intake and PRD revoke remain owner-only', () => {
for (const verb of ['trail', 'credentials', 'replies', 'messages', 'state', 'tasks.delete']) {
assert.equal(policy.VERB_RIGHTS[verb], null, verb);
}
for (const verb of ['request.answer.console', 'request.attach.console', 'request.cancel.console', 'request.mode.console', 'request.draft.console', 'prd.revoke.console']) {
assert.equal(policy.VERB_RIGHTS[verb], 'request.write', verb);
assert.equal(policy.RIGHTS[policy.VERB_RIGHTS[verb]].grantable, false);
}
});
test('M1 unknown verbs, rights and role property names never inherit an assignment', () => {
for (const name of ['future.verb', 'tasks.foo', 'task.create', 'owner', '__proto__', 'constructor', 'toString', '', undefined, null]) {
assert.equal(policy.VERB_RIGHTS[name], undefined);
assert.equal(policy.RIGHTS[name], undefined);
if (name !== 'owner') assert.equal(policy.ROLE_RIGHTS[name], undefined);
assert.throws(() => policy.rightLevel(name), { code: 'unknown-right' });
}
});
test('M1 only thread.write needs collaborator; every other known right needs viewer', () => {
for (const right of Object.keys(RIGHTS)) {
assert.equal(policy.rightLevel(right), right === 'thread.write' ? 'collaborator' : 'viewer', right);
}
for (const value of [{}, [], 1, new String('org.view')]) assert.throws(() => policy.rightLevel(value), { code: 'unknown-right' });
});
test('M1 policy records, grantable flags, bundles and normalized rights are frozen', () => {
for (const map of [policy.RIGHTS, policy.ROLE_RIGHTS, policy.VERB_RIGHTS]) {
assert.equal(Object.getPrototypeOf(map), null);
assert.ok(Object.isFrozen(map));
assert.throws(() => { map.extra = 'decide'; }, TypeError);
}
for (const descriptor of Object.values(policy.RIGHTS)) {
assert.ok(Object.isFrozen(descriptor));
assert.throws(() => { descriptor.grantable = !descriptor.grantable; }, TypeError);
}
for (const bundle of Object.values(policy.ROLE_RIGHTS)) {
assert.ok(Object.isFrozen(bundle));
assert.throws(() => bundle.push('decide'), TypeError);
}
assert.ok(Object.isFrozen(policy.normalizeRights(['thread.read'])));
});
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { validateBusinessDocument } from '../src/organization-config.mjs';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
test('organization enables only installed system types; absent means none', () => {
const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
const validate = () => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
assert.deepEqual(validate().plugins['system-type'] ?? [], []);
doc.plugins = { 'system-type': ['coding', 'montage'] };
assert.deepEqual(validate().plugins['system-type'], ['coding', 'montage']);
doc.plugins['system-type'] = ['not-installed'];
assert.throws(validate, /system-type.*not-installed/);
});
test('system type changes through the organization writer are owner-only', async t => {
const { rolesCopy, writeJson } = await import('./helpers.mjs');
const { pluginSettings } = await import('../src/plugin-settings.mjs');
const { rmSync } = await import('node:fs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
writeJson(file, doc);
const settings = pluginSettings({ dir: root, rolesDir });
const revision = settings.read('acme').revision;
assert.throws(() => settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, 'tester'), /human-only/);
assert.deepEqual(settings.read('acme').plugins, {});
assert.deepEqual(settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, doc.human).plugins['system-type'], ['coding']);
});
+9
View File
@@ -496,3 +496,12 @@ counts; it has no quota authority. See
record separation, bounded reads and per-key quota settings.
A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`, or `stdout`. The launcher validates and serializes that declaration; old sessions retain their recorded activity behavior.
### System types
`system-type` modules export `interfaceVersion = 1` and `systemType()`, returning
`{ id, label, description, toolsets }`. The id is the module directory name;
`toolsets` is a distinct list of toolset ids. The installed list is discovered
from module directories, and malformed modules refuse discovery. An organization
selects modules with `plugins.system-type`; an omitted list enables none. The
initial five modules declare no toolsets yet.
+2
View File
@@ -6,6 +6,8 @@ const VERBS = new Set([
"project.create",
"project.archive",
"project.unarchive",
"project.system-types",
"project.system-types.set",
"thread.list",
"thread.create",
"thread.messages",
+13
View File
@@ -80,6 +80,10 @@ export const KINDS = Object.freeze({
"model-provider": Object.freeze({
kind: "model-provider", interfaceVersion: 1, exports: Object.freeze(["catalogue", "preflight", "route"]), switch: "list",
}),
// Project system types are installed modules, enabled per organization.
"system-type": Object.freeze({
kind: "system-type", interfaceVersion: 1, exports: Object.freeze(["systemType"]), switch: "list",
}),
// A Console colour theme (stream S4, PLAN C5). A module is data only: theme() takes nothing and returns
// { displayName, tokens: { light, dim, dark } }
// where each mode is a flat map of the Console's colour custom properties to #rrggbb strings. theme.mjs
@@ -89,6 +93,15 @@ export const KINDS = Object.freeze({
theme: Object.freeze({
kind: "theme", interfaceVersion: 1, exports: Object.freeze(["theme"]), switch: "list",
}),
// Where an organization keeps its tasks (U4a, REQ-TASK-1). The organization file names one module;
// with none, the bus uses `store`, the task store built into each organization's own bus. `vikunja`
// is the optional Vikunja tracker, set up from the boot config's `trackers` as before. Core calls
// adapter({ trackers }) -> async ({ broker, credentials, businesses, processing }) -> { handle, timeout, close }
// once per bus, where handle(cap, verb, args) answers the eight task verbs for a role's cap and
// timeout is in milliseconds. Every module answers the same `tasks` read through the broker.
"task-tracker": Object.freeze({
kind: "task-tracker", interfaceVersion: 1, exports: Object.freeze(["adapter"]), switch: "one",
}),
});
export class PluginError extends Error {
+19
View File
@@ -0,0 +1,19 @@
import { loadInstalled } from './loader.mjs';
import { NAME } from './kinds.mjs';
// Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised.
export async function installedSystemTypes() {
const { modules, refused } = await loadInstalled('system-type');
if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' });
return modules.map(({ name, module }) => {
const value = module.systemType();
if (!value || Object.keys(value).sort().join(',') !== 'description,id,label,toolsets' || value.id !== name ||
typeof value.label !== 'string' || !value.label.trim() || /[\r\n]/.test(value.label) ||
typeof value.description !== 'string' || !value.description.trim() || /[\r\n]/.test(value.description) ||
!Array.isArray(value.toolsets) || value.toolsets.some(t => typeof t !== 'string' || !NAME.test(t)) ||
new Set(value.toolsets).size !== value.toolsets.length) {
throw Object.assign(new Error(`invalid system type ${name}`), { code: 'system-type-invalid' });
}
return structuredClone(value);
});
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "business-management", label: "Business management", description: "Coordinate and manage business operations.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "coding", label: "Coding", description: "Build and maintain software.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "creative-writing", label: "Creative writing", description: "Develop and write creative works.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "marketing", label: "Marketing", description: "Plan and produce marketing work.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "montage", label: "Mosaic Montage", description: "Create and manage media productions.", toolsets: [] };
}
@@ -0,0 +1,9 @@
// U4a (#1873, REQ-TASK-1): the default task-tracker module, the task store built into each
// organization's own bus. It needs no setting and no credential; packages/tasks/src/store.mjs has it.
import { storeAdapter } from '../../../tasks/src/store.mjs';
export const interfaceVersion = 1;
export function adapter() {
return storeAdapter();
}
@@ -0,0 +1,9 @@
// U4a (#1873): the optional Vikunja task-tracker module. It is the Vikunja adapter that was the only
// tracker before, unchanged, set up from the boot config's `trackers` (packages/tasks/src/adapter.mjs).
import { tasksAdapter } from '../../../tasks/src/adapter.mjs';
export const interfaceVersion = 1;
export function adapter({ trackers = {} } = {}) {
return tasksAdapter({ trackers });
}
+3 -1
View File
@@ -6,7 +6,9 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
const code = expected => e => e instanceof PluginError && e.code === expected;
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "theme"]);
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme", "task-tracker"]);
assert.deepEqual({ ...KINDS["task-tracker"], exports: [...KINDS["task-tracker"].exports] }, { kind: "task-tracker", interfaceVersion: 1, exports: ["adapter"], switch: "one" });
assert.ok(Object.isFrozen(KINDS["task-tracker"]) && Object.isFrozen(KINDS["task-tracker"].exports));
// Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch.
assert.deepEqual({ ...KINDS.theme, exports: [...KINDS.theme.exports] }, { kind: "theme", interfaceVersion: 1, exports: ["theme"], switch: "list" });
assert.ok(Object.isFrozen(KINDS.theme) && Object.isFrozen(KINDS.theme.exports));
+5 -5
View File
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../organization/src/organization-co
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] };
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] };
// A tree with one good module of each kind, already loaded.
async function loadedTree(t, options) {
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
const loader = await s.loader();
const result = await loader.load(business.plugins);
assert.deepEqual(result.refused, []);
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
assert.deepEqual(result.refused[0].kept, ["meter"]);
assert.deepEqual(s.loader.logged.slice(before), result.refused);
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
} finally {
if (lock) chmodSync(lock, 0o755);
}
@@ -232,7 +232,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
const loader = await s.loader();
// meter loads, absent refuses: the whole kind stays off, not half on.
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.deepEqual(loader.loaded(), result.loaded);
assert.deepEqual(loader.modules("usage-source"), []);
assert.equal(loader.logged.length, 1);
@@ -260,7 +260,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
// An absent key switches the kind off.
result = await s.loader.load({ "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
});
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLoader, loadInstalled } from '../src/loader.mjs';
test('installed system types describe the five modules and unknown names refuse', async () => {
const { modules, refused } = await loadInstalled('system-type');
assert.deepEqual(refused, []);
assert.deepEqual(modules.map(m => m.name), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
for (const { name, module } of modules) {
const info = module.systemType();
assert.equal(info.id, name);
assert.ok(info.label && info.description && Array.isArray(info.toolsets));
if (name === 'montage') assert.equal(info.label, 'Mosaic Montage');
}
const loader = createLoader({ log() {} });
const result = await loader.load({ 'system-type': ['unknown'] });
assert.equal(result.refused.length, 1);
assert.deepEqual(result.loaded['system-type'], []);
});
+23 -6
View File
@@ -1,6 +1,6 @@
{
"prd": "docs/prd/mosaic-stack.md",
"version": "1.19",
"version": "1.20",
"requirements": [
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per assistant session; the chief of staff may end a run, others need a decision" },
@@ -10,10 +10,13 @@
{ "id": "REQ-ROLE-6", "title": "Agents are proactive: each role contract says to act within its grants without waiting to be asked" },
{ "id": "REQ-COS-1", "title": "The chief of staff runs the organization: hires, terminates, launches; never talks to the user" },
{ "id": "REQ-COS-2", "title": "The PM asks the chief of staff for staff; a staff request names the roles and how many of each" },
{ "id": "REQ-ASSIST-1", "title": "The assistant is the user's main agent and passes organization work to the chief of staff" },
{ "id": "REQ-ASSIST-1", "title": "One assistant per user across organizations, with shared memory, passes work to each chief of staff" },
{ "id": "REQ-ASSIST-2", "title": "The broker limits the assistant to the user and the chief of staff in both directions; refusals logged" },
{ "id": "REQ-ASSIST-3", "title": "The assistant tells the user what other roles sent it in its own words and does not relay their text" },
{ "id": "REQ-COS-3", "title": "The chief of staff hires and fires role instances without a decision, within fixed bounds; the user forbids per agent" },
{ "id": "REQ-AGENT-1", "title": "Agents are configured at root, organization and project levels; a project's agents are their union" },
{ "id": "REQ-AGENT-2", "title": "The user can see and change agent, organization, project and session configuration through the write path" },
{ "id": "REQ-AGENT-3", "title": "The chief hires and fires at all levels; root-level changes need user approval" },
{ "id": "REQ-VAR-1", "title": "Variables in four layers, declared in code; limits intersect; unknown keys refused" },
{ "id": "REQ-VAR-2", "title": "One system config, never auto-written; secrets only as references the broker reads" },
{ "id": "REQ-CRED-1", "title": "Each role instance has its own Gitea and Vikunja token, held by the broker" },
@@ -37,7 +40,7 @@
{ "id": "REQ-WAIT-3", "title": "An action added to the vocabulary is a hard wait until a PRD version says it is not" },
{ "id": "REQ-MSG-1", "title": "Messages are addressed to a role, append-only, delivered to the current holder; assistant messages name a thread" },
{ "id": "REQ-MSG-2", "title": "Messages can be addressed by an instance name unique in its business; the message records the name and the instance" },
{ "id": "REQ-TASK-1", "title": "Tasks live in Vikunja, each citing a requirement id; the verbs enforce field writers" },
{ "id": "REQ-TASK-1", "title": "Each organization stores tasks through a task tracker module; Vikunja is optional" },
{ "id": "REQ-TASK-2", "title": "Polling is the source of truth: a 30-second board poll and an hourly reconcile" },
{ "id": "REQ-TASK-3", "title": "The installer points at an existing Vikunja or deploys the unmodified upstream image" },
{ "id": "REQ-TASK-4", "title": "The queue keeps the agents' lock, review rounds and audit log; no two-way sync" },
@@ -51,7 +54,15 @@
{ "id": "REQ-CLI-2", "title": "The assistant session mosaic talks to is launched and owned by the stack, not T3" },
{ "id": "REQ-WEB-1", "title": "The WebUI shows the CLI's data, a chat area of projects and threads, and Settings for agent limits" },
{ "id": "REQ-WEB-2", "title": "The Console resolves an open decision with one button per option, only for a human it signed in itself (OIDC)" },
{ "id": "REQ-WEB-3", "title": "The Console restarts the bus and reloads the organization file, gated and trailed; reload applies launch.instances live" },
{ "id": "REQ-WEB-3", "title": "Human-gated Console restart, bus restart, reload and agent model controls; dev-local owner needs no sign-in" },
{ "id": "REQ-WS-1", "title": "The WebUI always shows all organizations in a collapsible sidebar, with no switcher" },
{ "id": "REQ-WS-2", "title": "The Console builds shared views across the user's organization buses, subject to membership" },
{ "id": "REQ-WS-3", "title": "The main dashboard sums activity, work and metrics across the user's organizations and projects" },
{ "id": "REQ-WS-4", "title": "Each organization has a dashboard for docs, settings, KPIs, metrics and active sessions" },
{ "id": "REQ-WS-5", "title": "The projects list spans organizations, sorts and filters, and shows ownership" },
{ "id": "REQ-WS-6", "title": "One inbox holds items needing the user from all organizations, projects and sessions" },
{ "id": "REQ-WS-7", "title": "One view shows cross-organization activity; organizations and projects open side by side" },
{ "id": "REQ-WS-8", "title": "The tasks view aggregates every organization, with Kanban and list layouts and filters" },
{ "id": "REQ-LAUNCH-1", "title": "The chief of staff launches role sessions within the agent limits, logged and revocable; threads start on demand" },
{ "id": "REQ-LIMIT-1", "title": "Agent limits have no code ceiling and are set from WebUI Settings and mosaic: total, per provider, per model" },
{ "id": "REQ-LIMIT-2", "title": "A daily quota per OAuth account or CLIProxyAPI client key, counting only the stack's own use" },
@@ -59,6 +70,8 @@
{ "id": "REQ-LIMIT-4", "title": "Usage sources and limit checks are the first plug-in kinds; an unknown turn is logged and shown, never counted as zero" },
{ "id": "REQ-PLUG-1", "title": "Every part the core calls out to is a plug-in kind; modules load from packages/plugins, switched on in the org file" },
{ "id": "REQ-PLUG-2", "title": "New call-outs go through a plug-in kind from the start; a hard-coded part converts when a row changes it" },
{ "id": "REQ-SYS-1", "title": "System types are a plug-in kind; organizations enable a list of modules" },
{ "id": "REQ-SYS-2", "title": "Projects use enabled system types and their agents receive the corresponding toolsets" },
{ "id": "REQ-HARN-1", "title": "The meta-harness generates each session's launch bundle from role and variables" },
{ "id": "REQ-HARN-2", "title": "Credential scope, verbs, the tool limit and a container enforce the rules" },
{ "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" },
@@ -78,7 +91,7 @@
{ "id": "REQ-DISK-14", "title": "Events" },
{ "id": "REQ-ORG-1", "title": "Business becomes organization; an install holds 1-N, each with its own bus as tenant boundary" },
{ "id": "REQ-ORG-2", "title": "Data model: install > organization > projects > threads" },
{ "id": "REQ-USER-1", "title": "Users and RBAC at install and organization level; an agent reaches only its allowed projects and threads" },
{ "id": "REQ-USER-1", "title": "Users and RBAC at install and organization level; agents are scoped to one organization except the assistant" },
{ "id": "REQ-USER-2", "title": "Only a thread's owning user, their assistant and the chief of staff's replies reach the thread" },
{ "id": "REQ-THREAD-1", "title": "Threads live under projects" },
{ "id": "REQ-THREAD-2", "title": "The user creates projects and named threads; a thread holds independent assistant sessions, each with kept history" },
@@ -91,6 +104,8 @@
{ "id": "REQ-SESS-1", "title": "Each session shows its lifetime count of completed compactions on pi and Claude Code; an unproven count shows unknown" },
{ "id": "REQ-SESS-2", "title": "Clear context retires a session after a safe stop and starts a fresh successor with none of the old context" },
{ "id": "REQ-SESS-3", "title": "Every session start passes the launch checks: authority, route, revocation, kill switch, limits and admission" },
{ "id": "REQ-ACCT-1", "title": "Deployed systems require sign-in; users create and belong to organizations" },
{ "id": "REQ-ACCT-2", "title": "Organization owners invite users with a chosen grant" },
{ "id": "REQ-LIB-1", "title": "A human-set library maps the user's repository onto universes, books, types, playgrounds, supporting and archive folders" },
{ "id": "REQ-LIB-2", "title": "Scope comes from the path or a DOCX's source, lookups default to canon, and only canon files are ever cited as canon" },
{ "id": "REQ-LIB-3", "title": "Universes stay separate; a crossover is a hybrid universe only a human creates, reading its sources' canon" },
@@ -102,6 +117,8 @@
{ "id": "REQ-LIB-9", "title": "The Console's DOCX editor: only a human saves, each save is one commit, and the editor code is vendored and pinned" },
{ "id": "REQ-INST-1", "title": "The installer offers optional components: pinned, health-checked, removable, switched on in config; first is SearXNG" },
{ "id": "REQ-INST-2", "title": "With no OIDC provider to configure, the installer deploys a bundled Pocket ID, Q203 (post-v1)" },
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" }
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" },
{ "id": "REQ-SETUP-1", "title": "Quick Setup initializes the project records" },
{ "id": "REQ-SETUP-2", "title": "The assistant offers an onboarding interview and drafts a north star and first PRD for user approval" }
]
}
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more