Merge refactor 2bed0aebe into E2E5 fixes

This commit is contained in:
jason.woltje committed 2026-11-09 13:30:24 -06:00
commit 8a8edc44b6
311 files changed
+9055 -5497

No files matched your search

+15 -7
View File
@@ -251,16 +251,24 @@ Exit codes: `0` ok · `1` failed · `2` invalid or refused · `3` uncertain,
retry the same op (for a review request, the retry sends nothing; resolve or
abandon it) · `4` usage. Details: `packages/queue/README.md`.
## Business files and role instances (`scripts/mosaic business`)
## Organization files and role instances (`scripts/mosaic org config`, `scripts/mosaic org instances`)
```bash
scripts/mosaic business validate <business>
scripts/mosaic business resolve <business> <instance> [--project <id>]
scripts/mosaic business instances <business> [--json]
scripts/mosaic business instance add <business> <instance> --definition <def> [--vars <file>] [--launch] --revision <rev>
scripts/mosaic business instance set <business> <instance> [--definition <def>] [--vars <file>] [--launch | --no-launch] --revision <rev>
scripts/mosaic org config validate <org>
scripts/mosaic org config resolve <org> <instance> [--project <id>]
scripts/mosaic org instances <org> [--json]
scripts/mosaic org instance add <org> <instance> --definition <def> [--vars <file>] [--launch] --revision <rev>
scripts/mosaic org instance set <org> <instance> [--definition <def>] [--vars <file>] [--launch | --no-launch] --revision <rev>
```
`scripts/mosaic business validate|resolve|instances|instance|veto` is the old
spelling of these commands, with the same arguments, and it still works. Both
spellings run the same code and give the same status, stdout and stderr.
Only a validate/resolve usage error differs: it prints the usage of the
spelling you used. Every other validate/resolve stderr line, refusals and
warnings included, starts `mosaic business: ` in both spellings. `scripts/mosaic org validate FILE` is a different command: it
checks a draft file by path and prints `valid`.
Reads `businesses/<business>.json` next to the system config
(`$MOSAIC_CONFIG` or `~/.config/mosaic-dev/`), the version 2 role files in
`roles/` (or `$MOSAIC_ROLES_DIR`) and each project's `.mosaic/project.json`.
@@ -271,7 +279,7 @@ its narrowed tools, network and authority, and its credential references.
The business file must belong to you and not be group or other writable.
Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
config problem · `4` usage or a required file missing. Details:
`packages/business/README.md`.
`packages/organization/README.md`.
`mosaic credentials status|issue|rotate|revoke|delete` (row 199) goes to
`packages/cli` and the bus's human-only credential verbs: an agent's own
+1 -1
View File
@@ -157,7 +157,7 @@ Kinds of work:
| Talk `#/talk` | the conversation History view exists; the live PM session is row 41 | blocked | Row 41 (S6). Reuse the controller/observer rules of `packages/conversation` |
| Queue `#/queue` | `packages/queue/src/store.mjs` `list`/`show`; no endpoint | new view | Read-only. Rows move only through `queue move` |
| Ledger `#/ledger` | `packages/ledger` `summarize`, `queueChecks` | new view | Read-only |
| Business `#/business` | `packages/business` `loadBusiness`, `loadRole`, `resolveInstance`, `vocabulary.mjs` | new view | Read-only. A missing or invalid file shows the refusal state. Credential refs as metadata only |
| Business `#/business` | `packages/organization` `loadBusiness`, `loadRole`, `resolveInstance`, `vocabulary.mjs` | new view | Read-only. A missing or invalid file shows the refusal state. Credential refs as metadata only |
| Runs and releases `#/runs` | readers live inside `scripts/mosaic-task.mjs` and `scripts/release.sh`, not exported | backend | Extract a read-only module first |
| Library `#/library` | no meta-harness in committed code | blocked | Row 41 (S6) creates `packages/harness` |
| Settings `#/settings` | appearance exists in the webui header; notifier config `readNotifyConfig` | extend | Appearance, notifications (binding names only), about (loopback, release). Read-only apart from appearance |
@@ -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}}
+3 -3
View File
@@ -327,7 +327,7 @@ After install, `stack-copy.sh check --units external` reads the copy the units r
Before running `case2-install.sh`, check the exact file you will pass as `--business-file`. Each
non-library `projects` key must equal the `id` in `<root>/.mosaic/project.json`, and that file must exist
in the stack's user's clone. Library entries are skipped by validate (`packages/business/src/cli.mjs:72`).
in the stack's user's clone. Library entries are skipped by validate (`packages/organization/src/config-command.mjs:95`).
Run this read-only check as `mosaic` with section 2's pinned node:
```sh
@@ -356,7 +356,7 @@ MOSAIC_PROJECT_KEYS
For .53 expect `ok project stack: /srv/mosaic/src/mosaic-stack/.mosaic/project.json has id stack`
(`.mosaic/project.json:3`, `docs/guides/case1-business.mosaic-stack.json:10-11`). Any STOP or nonzero exit
stops the install. Fix a mismatched business key, not the project id (mos-1563). The install otherwise
reaches that refusal at step 7, after making the copy (`packages/business/src/cli.mjs:80`,
reaches that refusal at step 7, after making the copy (`packages/organization/src/config-command.mjs:103`,
`scripts/case2-install.sh:178`, `scripts/case2-install.sh:217`).
The stack's user's `scripts/case2-install.sh` (C2-13b, design 10 step 2b) makes the first copy with
@@ -365,7 +365,7 @@ harness, so the host needs to reach the npm registry during the install. The ins
`release.sh package` or `activate`.
A missing `.mosaic/project.json` only warns: `project <id>: no project file at <path>; it has no project
variables`. Validate reports that project `absent` and continues (`packages/business/src/cli.mjs:74-78`).
variables`. Validate reports that project `absent` and continues (`packages/organization/src/config-command.mjs:97-101`).
An install can therefore pass step 7 without the project's variables. It captures validate's output
and prints `business <id> validates` (`scripts/case2-install.sh:217-221`). Treat the missing-file warning as a stop,
place the correct project file and repeat the check above before continuing (Duckworth 323 N2;
@@ -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.
+3 -3
View File
@@ -718,7 +718,7 @@ keys with `invalid-request` before the writer runs (sage-0918). `instances` take
answers the business's instances, their definitions, vars and launch membership, plus the
definitions `instances.set` accepts. `instances.set` takes `{revision,instance,op,definition?,
vars?,launch?}`. `op` is `add` or `change`, and every other key is `invalid-request`. The
business package's `instance-settings.mjs` writes through the organization writer, which
organization package's `instance-settings.mjs` writes through the organization writer, which
logs the human as the actor, or `tester` for a tester's write. Its refusals pass through as `organization-*` codes, among them
`organization-credentials-out-of-scope` for a definition that declares credentials. The answer
carries `restart: true` after an add or a definition change: the broker keeps the roles it
@@ -731,8 +731,8 @@ tester may send `routes.set` (row 175), logged with actor `tester`; its `routes`
`ORGANIZATION_KEYS`, so the chief of staff's `change_organization` can't send a `routes` key.
The chief reaches a route only through the assistant's whole vars (Q169), and that write runs
the same route checks. `routes` takes no args and answers the routing table;
`routes.set` takes exactly `{revision,routes}`. The business package's `launch-settings.mjs`
writes them with the human as actor (`packages/business/README.md`, "Routes"). `harness-off`,
`routes.set` takes exactly `{revision,routes}`. The organization package's `launch-settings.mjs`
writes them with the human as actor (`packages/organization/README.md`, "Routes"). `harness-off`,
`harness-refused` and `thinking-unsupported` pass through as they are, from `routes.set`,
`instances.set` and `organization.set` alike. The runtime builds the write checks' `levels`
from `harnessLevels()`, which loads each harness module in the tree once at start, switched on
+1 -1
View File
@@ -7,7 +7,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
+2 -27
View File
@@ -1,27 +1,2 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../business/src/vocabulary.mjs';
// Adapt S1's validated loadBusiness + resolveInstance outputs, without loading or writing files.
export function busBusiness(business, resolved) {
if (!business || typeof business.id !== 'string' || !business.roles || !resolved)
throw new BusError('invalid-business');
const out = structuredClone(business);
for (const role of Object.keys(out.roles)) {
const r = resolved[role];
if (
!r ||
r.business !== business.id ||
r.instance !== role ||
r.definition !== out.roles[role].definition ||
!r.limits?.authority
)
throw new BusError('invalid-resolution');
out.roles[role].authority = structuredClone(r.limits.authority);
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
const hostKeys = Object.entries(business.roles[role].credentials ?? {}).filter(([kind]) => isHostKind(kind));
out.roles[role].credentials = { ...structuredClone(r.credentials ?? {}), ...structuredClone(Object.fromEntries(hostKeys)) };
}
return out;
}
// Old path of organization-context.mjs (rename design r2, stage B): busBusiness is busOrganization.
export { busOrganization as busBusiness } from './organization-context.mjs';
+1 -1
View File
@@ -1,5 +1,5 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../business/src/vocabulary.mjs';
import { isHostKind } from '../../organization/src/vocabulary.mjs';
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
+1 -1
View File
@@ -6,7 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { BusError } from './broker.mjs';
import { credentialKind, isHostKind, ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { credentialKind, isHostKind, ID_PATTERN } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
};
+2 -2
View File
@@ -17,8 +17,8 @@ import { join } from 'node:path';
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
import { day, privateDir } from './credentials.mjs';
import { definitionSha256, grantTag } from './host-grant.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl, CREATIVE_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl, CREATIVE_DEFINITIONS } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
+3 -3
View File
@@ -17,9 +17,9 @@ import { dirname, join } from 'node:path';
import { BusError } from './broker.mjs';
import { day, parseHostKey, privateDir } from './credentials.mjs';
import { runHost, HOST_LIMITS } from './host-run.mjs';
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../business/src/hosts.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { HOST_ID, ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../organization/src/hosts.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { HOST_ID, ID_PATTERN } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
+1
View File
@@ -2,5 +2,6 @@ export { Broker, BusError, ACTIONS, TASK_VERBS } from './broker.mjs';
export { startBroker } from './runtime.mjs';
export { Client } from './client.mjs';
export { views } from './views.mjs';
export { busOrganization } from './organization-context.mjs';
export { busBusiness } from './business.mjs';
export { parseAddress } from './address.mjs';
+28
View File
@@ -0,0 +1,28 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../organization/src/vocabulary.mjs';
// Adapt S1's validated loadOrganization (loadBusiness) + resolveInstance outputs, without loading
// or writing files. The result keeps the v1 business shape and error codes (rename design r2).
export function busOrganization(business, resolved) {
if (!business || typeof business.id !== 'string' || !business.roles || !resolved)
throw new BusError('invalid-business');
const out = structuredClone(business);
for (const role of Object.keys(out.roles)) {
const r = resolved[role];
if (
!r ||
r.business !== business.id ||
r.instance !== role ||
r.definition !== out.roles[role].definition ||
!r.limits?.authority
)
throw new BusError('invalid-resolution');
out.roles[role].authority = structuredClone(r.limits.authority);
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
const hostKeys = Object.entries(business.roles[role].credentials ?? {}).filter(([kind]) => isHostKind(kind));
out.roles[role].credentials = { ...structuredClone(r.credentials ?? {}), ...structuredClone(Object.fromEntries(hostKeys)) };
}
return out;
}
+3 -3
View File
@@ -31,9 +31,9 @@ import { gitEnv, runGit } from '../../seat/src/checkout.mjs';
import { initializeWorking, inspectWorkingRepository, workingRepo } from '../../seat/src/working.mjs';
import { initProject, writeHumanFile } from '../../project/src/index.mjs';
import { SAFE, identity } from '../../project/src/git.mjs';
import { loadProject } from '../../business/src/project.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { managedPaths, markerBytes, markerFile, readMarker } from '../../business/src/project-bootstrap.mjs';
import { loadProject } from '../../organization/src/project.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { managedPaths, markerBytes, markerFile, readMarker } from '../../organization/src/project-bootstrap.mjs';
const INTENT_PATH = 'spec/north-star.md';
const LAYOUT_SUBJECT = 'Initialize project layout v1';
+1 -1
View File
@@ -10,7 +10,7 @@
// Not verified here, because nothing the host holds can: that the tracker board the owner selected exists.
// That is checked when the first task is created, not before the owner confirms.
import { prepareBootstrap, prepareExistingPlan } from './request-bootstrap.mjs';
import { loadProject } from '../../business/src/project.mjs';
import { loadProject } from '../../organization/src/project.mjs';
import { digest, refuse } from './request-store.mjs';
const positive = n => Number.isSafeInteger(n) && n > 0;
+2 -2
View File
@@ -1,8 +1,8 @@
// Trusted host journal. No SQL, transition or effect interface is exposed to workers.
import { createHash } from 'node:crypto';
import { isAbsolute, normalize } from 'node:path';
import { ID_PATTERN, parseGiteaUrl } from '../../business/src/vocabulary.mjs';
import { checkVars } from '../../business/src/vars.mjs';
import { ID_PATTERN, parseGiteaUrl } from '../../organization/src/vocabulary.mjs';
import { checkVars } from '../../organization/src/vars.mjs';
export class RequestError extends Error {
constructor(code) { super(code); this.code = code; }
+15 -15
View File
@@ -11,18 +11,18 @@ import { requestWorkspace } from './request-workspace.mjs';
import { readReviewedArtifact } from '../../seat/src/artifact.mjs';
import { executeBootstrap } from './request-bootstrap.mjs';
import { hostPreparation } from './request-host.mjs';
import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs';
import { managedPaths, managedState } from '../../organization/src/project-bootstrap.mjs';
import * as projectModule from '../../project/src/index.mjs';
import * as libraryModule from '../../library/src/index.mjs';
import { Broker, projectArchived, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { launchSettings } from '../../organization/src/launch-settings.mjs';
import { readSessions, sessionsFile, stopSession } from '../../seat/src/session.mjs';
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 { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { ProxyPage } from './proxy-page.mjs';
import { createModelCatalogue } from './model-catalogue.mjs';
@@ -30,22 +30,22 @@ import { sha256 } from '../../model-proxy/src/common.mjs';
import { initializeProxy } from '../../model-proxy/src/settings.mjs';
import { createProxyLock } from './proxy-lock.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { getProjectRemote } from '../../business/src/project-remotes.mjs';
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { selectModelProvider } from '../../plugins/src/model-provider-selection.mjs';
import { busBusiness } from './business.mjs';
import { busOrganization } from './organization-context.mjs';
import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart, giteaReference } from './gitea-grant.mjs';
import { verifyPrincipal } from './principal.mjs';
import { loadAccess } from '../../business/src/access.mjs';
import { loadAccess } from '../../organization/src/access.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
import { createUsage } from './usage.mjs';
@@ -559,7 +559,7 @@ export async function startBroker({
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = withoutPending(id, busBusiness(b, resolved));
const next = withoutPending(id, busOrganization(b, resolved));
if (!sameFixed(booted.get(id), next)) {
reloadLog({ event: 'reload-needs-restart', business: id });
return false;
@@ -590,7 +590,7 @@ export async function startBroker({
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const full = busBusiness(b, resolved);
const full = busOrganization(b, resolved);
const entry = full.projects?.[project];
const here = managedPaths({ dataRoot, business: id, project });
if (!entry || entry.kind === 'library' || entry.root !== here.root || entry.repo !== here.repo
@@ -734,7 +734,7 @@ export async function startBroker({
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
const live = withoutPending(id, busBusiness(current, resolved));
const live = withoutPending(id, busOrganization(current, resolved));
if (!sameFixed(booted.get(id), live) || canonicalJson(live.projects) !== known.get(id)) throw new BusError('project-remote-protected');
if (!Object.hasOwn(live.projects, args.project)) throw new BusError('project-remote-unknown');
if (verb === 'project.remote.get.console') return getProjectRemote(writer, id, args.project);
@@ -13,9 +13,9 @@ import { prepareBootstrap } from '../src/request-bootstrap.mjs';
import { hostPreparation, staffFor } from '../src/request-host.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { managedState } from '../../business/src/project-bootstrap.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { managedState } from '../../organization/src/project-bootstrap.mjs';
import { workingRepo } from '../../seat/src/working.mjs';
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
+4 -4
View File
@@ -4,9 +4,9 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { busBusiness } from '../src/business.mjs';
import { resolveInstance, validateBusinessDocument } from '../../business/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
const { Broker } = await import('../src/broker.mjs').catch((e) => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
throw e;
@@ -399,7 +399,7 @@ test('the shipped coder role raises task.reassign as gated to the human; task.sc
const resolved = {};
for (const instance of Object.keys(business.roles))
resolved[instance] = resolveInstance({ system: systemFor(root), business, instance });
const { b, agent, human } = setup(t, { demo: busBusiness(business, resolved) });
const { b, agent, human } = setup(t, { demo: busOrganization(business, resolved) });
const c = agent('coder');
call(b, c, 'role.claim');
const reassign = raise(b, c, 'task.reassign', { target: 'vikunja:3/7' });
+6 -6
View File
@@ -8,12 +8,12 @@ import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../business/src/vocabulary.mjs';
import { HIRE_ALLOWED } from '../../organization/src/vocabulary.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
@@ -1041,7 +1041,7 @@ test('row 194 T8, T9, T18: through the runtime the chief hires and fires with th
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busBusiness(b, resolved);
return busOrganization(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
+2 -2
View File
@@ -7,8 +7,8 @@ import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
import { organizationWriter, setReplyLimits } from '../../business/src/organization.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { organizationWriter, setReplyLimits } from '../../organization/src/organization.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { citationWorld, citedBody, fixtureCite, guardCases } from './citation-helpers.mjs';
+1 -1
View File
@@ -4,7 +4,7 @@ import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { checkReply, GuardError, libraryReader } from '../../library/src/guard.mjs';
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { THREAD_DEFINITIONS } from '../../organization/src/vocabulary.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { mapping, repo, MARKER } from '../../library/tests/helpers.mjs';
+2 -2
View File
@@ -5,8 +5,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { validateBusinessDocument } from '../../business/src/business.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { validateBusinessDocument } from '../../organization/src/organization-config.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
test('C2-06 runtime reads changed and unreadable reply limits without restarting', async t => {
+1 -1
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
// Row 213 (C2-08, #1714, Case 2 design 5.1): a creative instance talks with the user only, and it has
// no decision channel (M6). The messaging lists are read from the shipped role files.
+1 -1
View File
@@ -10,7 +10,7 @@ import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
@@ -15,7 +15,7 @@ const ROLES = { coder: { definition: 'coder', authority: { withinRole: [], cross
async function setup(t, limit = 0) {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-quota-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'limit-check': ['daily-quota'], harness: ['pi', 'claude-code'] };
doc.launch.account = { name: 'jason-max', credential: 'CLAUDE_CODE_OAUTH_TOKEN' };
+1 -1
View File
@@ -10,7 +10,7 @@ import { fixture, notifyConfig } from '../../cli/tests/helpers.mjs';
import { initializeControl, transition } from '../../processing-control/src/state.mjs';
import { verifyPrincipal } from '../src/principal.mjs';
import { checkConsole } from '../../webui/src/sso-config.mjs';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
const uid=process.getuid(), nonce='a'.repeat(64);
const paths={ console:'/tree/cli.mjs', consoleCli:'/tree/console-cli.mjs' };
+1 -1
View File
@@ -11,7 +11,7 @@ import { Broker, registrySha256 } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant, definitionSha256 } from '../src/host-grant.mjs';
import { hostRecord, hostGrantRecord } from './host-fixtures.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
@@ -6,7 +6,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
// Break caught: missing runtime setter routing or losing the organization receipt.
test('row 298 runtime: human scope approval writes the file and organization history at the supplied revision', async t => {
+2 -2
View File
@@ -4,7 +4,7 @@
// Gitea grants run against the fake Gitea in fake-gitea-api.mjs on 127.0.0.1; host grants stop at the
// spend, as no host key store is configured, and nothing connects to a host. Every token is a fake the
// fake made and none is printed. The organization writer is a fake here; the real one is
// packages/business/tests/grant-scope.test.mjs.
// packages/organization/tests/grant-scope.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
@@ -16,7 +16,7 @@ import { Credentials } from '../src/credentials.mjs';
import { giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
import { hostGrant, grantKeys, fingerprint } from '../src/host-grant.mjs';
import { Store } from '../src/store.mjs';
import { checkGrantScope, grantScopeOf } from '../../business/src/vocabulary.mjs';
import { checkGrantScope, grantScopeOf } from '../../organization/src/vocabulary.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { hostRecord, grantDecision } from './host-fixtures.mjs';
+1 -1
View File
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker, ACTIONS, HARD_WAITS } from '../src/broker.mjs';
import { ACTIONS as VOCABULARY, GATED_ONLY } from '../../business/src/vocabulary.mjs';
import { ACTIONS as VOCABULARY, GATED_ONLY } from '../../organization/src/vocabulary.mjs';
import { fixture } from './spec-fixtures.mjs';
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
+2 -2
View File
@@ -17,7 +17,7 @@ import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant } from '../src/host-grant.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs';
@@ -422,7 +422,7 @@ test('T24: no literal gitea and vikunja kind list outside vocabulary.mjs, and th
return existsSync(src) ? readdirSync(src).filter((n) => n.endsWith('.mjs')).map((n) => `packages/${pkg}/src/${n}`) : [];
});
const code = (file) => readFileSync(join(REPO, file), 'utf8').split('\n').filter((l) => !/^\s*(\/\/|\*)/.test(l)).join('\n');
const lists = files.filter((file) => !file.endsWith('business/src/vocabulary.mjs') &&
const lists = files.filter((file) => !file.endsWith('organization/src/vocabulary.mjs') &&
/\[\s*['"]gitea['"]\s*,\s*['"]vikunja['"]\s*\]/.test(code(file)));
assert.deepEqual(lists, []);
const uses = files.filter((file) => /credentials\.(use|useFor)\(/.test(code(file))).sort();
+1 -1
View File
@@ -60,7 +60,7 @@ test('row 165: instances and instances.set refuse every non-human session but th
test('row 165 runtime: an add and a change reach the organization file with the human as actor; each refusal keeps the bytes and logs nothing', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'instances-runtime-'));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
+1 -1
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { createContinuations } from '../src/request-continuation.mjs';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
// Row 146 (#1640): a message.send to a role nobody holds tells the business's launch.by
// instance once, and the result says so. The assistant's lists are the shipped ones, as in
@@ -22,7 +22,7 @@ import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
import * as library from '../../library/src/index.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { grantDecision } from './host-fixtures.mjs';
import { systemFor } from '../../business/tests/helpers.mjs';
import { systemFor } from '../../organization/tests/helpers.mjs';
const AT = '2026-10-22T00:00:00.000Z';
const REV = 'b'.repeat(64);
+5 -5
View File
@@ -7,13 +7,13 @@ import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { busBusiness } from '../src/business.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const store = (agent, service) => ({ store: true, [service === 'gitea' ? 'rotateBy' : 'expires']: FAR_DATE });
@@ -42,7 +42,7 @@ async function setup(t, { drop = [], seed = null } = {}) {
const b = loadBusiness('demo', { dir: root, rolesDir });
const resolved = {};
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
const boot = busBusiness(b, resolved);
const boot = busOrganization(b, resolved);
for (const name of drop) delete boot.roles[name].credentials;
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: boot }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => {
+5 -5
View File
@@ -366,10 +366,10 @@ test('row 194 V5 retry: a retired name deletes again only after workspace-rename
test('row 194 V5 runtime: a delete request, the take and lifecycleDelete commit the file with retired, log the human and reload so the name no longer binds', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { busBusiness } = await import('../src/business.mjs');
const { loadBusiness } = await import('../../business/src/business.mjs');
const { resolveInstance } = await import('../../business/src/resolve.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { busOrganization } = await import('../src/organization-context.mjs');
const { loadBusiness } = await import('../../organization/src/organization-config.mjs');
const { resolveInstance } = await import('../../organization/src/resolve.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'lifecycle-runtime-'));
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
@@ -377,7 +377,7 @@ test('row 194 V5 runtime: a delete request, the take and lifecycleDelete commit
const b = loadBusiness('demo', { dir: root, rolesDir });
const resolved = {};
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busBusiness(b, resolved) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(b, resolved) }, 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 ask = (verb, args) => bus.broker.request(human, { verb, args });
+1 -1
View File
@@ -13,7 +13,7 @@ const CODER = ['tokens', 'instances', 'coder'];
async function setup(t, plugins, limit = 0) {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'mosaic-limits-tokens-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'limit-check': plugins, harness: ['pi'] };
doc.launch.limits = { ...doc.launch.limits, tokens: { instances: { coder: { limit, since: SINCE } } } };
+2 -2
View File
@@ -17,14 +17,14 @@ test('limits binds actor from human proof and rejects role, reader, a tester set
b.request(human,{verb:'limits'});b.request(human,{verb:'limits.set',args:patch});b.request(tester,{verb:'limits'});assert.deepEqual(calls,[['demo','limits',{},'jason'],['demo','limits.set',patch,'jason'],['demo','limits',{},'tester']]);
});
test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');const {readFileSync}=await import('node:fs');
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');const {readFileSync}=await import('node:fs');
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-runtime-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},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 view=bus.broker.request(human,{verb:'limits'});const done=bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:0}}});assert.equal(done.limits.total,0);assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).launch.limits.total,0);const receipt=JSON.parse(readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8').trim().split('\n').at(-1));assert.equal(receipt.actor,'jason');assert.equal(receipt.state,'committed');
});
test('uncertain organization receipts leave bus startup and reads alone but refuse writes until reconciled',async t=>{
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');const {organizationWriter}=await import('../../business/src/organization.mjs');const {readFileSync}=await import('node:fs');
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');const {organizationWriter}=await import('../../organization/src/organization.mjs');const {readFileSync}=await import('node:fs');
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-recovery-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
const writer=organizationWriter({dir:root,rolesDir}),original=readFileSync(file,'utf8'),revision=writer.read('demo').revision,next=structuredClone(doc);next.launch.limits.total=7;
const interrupted=organizationWriter({dir:root,rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>interrupted.write('demo',next,{revision,actor:'jason'}));const external=structuredClone(doc);external.launch.limits.total=12;writeJson(file,external);const bytes=readFileSync(file,'utf8');
+2 -2
View File
@@ -36,7 +36,7 @@ test('disabled, refused, and failed providers never retain authorization', async
test('launch admission refreshes selection and refuses a removed model and incompatible harness', async t => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const { mkdtempSync, mkdirSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os'); const { join } = await import('node:path');
const root=mkdtempSync(join(tmpdir(),'model-admission-')); t.after(()=>rmSync(root,{recursive:true,force:true}));
@@ -68,7 +68,7 @@ test('an account mutation fences reads begun before or during the write', async
});
test('a different enabled fixture provider supplies legacy mapping and admission without a core case', async t => {
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');
const {mkdtempSync,mkdirSync,rmSync}=await import('node:fs');const {tmpdir}=await import('node:os');const {join}=await import('node:path');
const root=mkdtempSync(join(tmpdir(),'second-provider-'));t.after(()=>rmSync(root,{recursive:true,force:true}));const rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');
doc.plugins={...doc.plugins,harness:['pi'],'model-provider':['second-fixture']};doc.launch.models['fixture-legacy']={provider:'fixture',model:'fixture-model'};doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-legacy',thinking:'low'};
@@ -11,8 +11,8 @@ import { loadSystem, bootConfig } from '../../cli/src/config.mjs';
import { createLauncher } from '../../cli/src/launcher.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { gitEnv } from '../../seat/src/checkout.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { writeJson } from '../../business/tests/helpers.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { writeJson } from '../../organization/tests/helpers.mjs';
import { readSessions, runsDir, sessionsFile, SESSIONS_VERSION, launchLogFile } from '../../seat/src/session.mjs';
export const waitFor = async (read, message, ms=15000) => { const end=Date.now()+ms;while(Date.now()<end){const value=await read();if(value)return value;await new Promise(r=>setTimeout(r,20));}throw Error(message); };
export const pause = ms => new Promise(r=>setTimeout(r,ms));
@@ -7,9 +7,9 @@ import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
import { startHost } from '../../cli/src/host.mjs';
import { createLauncher } from '../../cli/src/launcher.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { sessionsFile, launchLogFile } from '../../seat/src/session.mjs';
import { reviewFixture } from './model-review-fixtures.mjs';
const id = key => createHash('sha256').update(key).digest('hex').slice(0, 12);
@@ -42,7 +42,7 @@ async function ipcFixture(t, { down = true, ended = false, live = false } = {})
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system: f.system, business, instance });
const logs = [];
host = await startHost({ business: 'demo', log: line => logs.push(line), boot: {
dataRoot: f.dataRoot, readers: ['demo'], businesses: { demo: busBusiness(business, resolved) },
dataRoot: f.dataRoot, readers: ['demo'], businesses: { demo: busOrganization(business, resolved) },
organization: { dir: f.configDir, rolesDir: f.rolesDir, system: f.system },
proxy: { port: Number(new URL(proxy.baseUrl).port), binary: '/fixture-unused' },
} });
+6 -6
View File
@@ -10,11 +10,11 @@ import { Credentials } from '../src/credentials.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { ModelKeys, REVOKE_TIMEOUT_MS, REVOKE_POLL_MS } from '../src/model-keys.mjs';
import { startBroker } from '../src/runtime.mjs';
import { busBusiness } from '../src/business.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { Registry } from '../../seat/src/session.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const hash = s => createHash('sha256').update(s).digest('hex').slice(0, 12);
const failCode = code => e => e.code === code;
async function fixture(t, { delayed = 0 } = {}) {
@@ -192,7 +192,7 @@ async function bootBusiness(f, binding = {}) {
fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 });
// Fresh mkdtemp fixture + exclusive mkdir above: no prior session registry can exist.
new Registry(join(f.root, 'data'));
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busBusiness(business, resolved) },
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busOrganization(business, resolved) },
organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
f.closers.push(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
@@ -320,7 +320,7 @@ test('beta bus registers while alpha bus runs and alpha key stays accepted', asy
const business = loadBusiness('beta', { dir: f.configDir, rolesDir: alpha.rolesDir }), resolved = {};
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system: alpha.system, business, instance });
const betaRoot = join(f.root, 'beta-bus'); fs.mkdirSync(betaRoot, { mode: 0o700 }); new Registry(betaRoot);
const beta = await startBroker({ dataRoot: betaRoot, businesses: { beta: busBusiness(business, resolved) },
const beta = await startBroker({ dataRoot: betaRoot, businesses: { beta: busOrganization(business, resolved) },
organization: { dir: f.configDir, rolesDir: alpha.rolesDir, system: alpha.system },
proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding, org: { id: 'beta' } });
f.closers.push(() => beta.close());
@@ -5,10 +5,10 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { lifecycleFixture } from './model-lifecycle-fixtures.mjs';
async function hireFixture(t,{revoking=false}={}){
const f=await lifecycleFixture(t),old=f.create(['demo/orphan']);await old.manager.reconcile();const issued=await old.manager.ensure('demo/orphan');
@@ -22,7 +22,7 @@ async function hireFixture(t,{revoking=false}={}){
const dataRoot=join(f.root,'data');fs.mkdirSync(dataRoot,{mode:0o700});
// Capture only value-free runtime proxy receipts; never print a leaked value.
const log=[],original=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){log.push(String(chunk));const callback=args.find(a=>typeof a==='function');callback?.();return true;}return original.call(this,chunk,...args);};f.own(()=>{process.stderr.write=original;});
const bus=await startBroker({dataRoot,businesses:{demo:busBusiness(business,resolved)},organization:{dir:f.configDir,rolesDir,system},proxy:{port:Number(new URL(f.baseUrl).port),binary:'/fixture-unused'}});
const bus=await startBroker({dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:f.configDir,rolesDir,system},proxy:{port:Number(new URL(f.baseUrl).port),binary:'/fixture-unused'}});
f.own(()=>bus.close());
assert.equal(f.state.keys.includes(issued.key),false,'runtime boot reconciled omitted orphan');assert.equal(bus.credentials.modelRecord('demo/orphan')===null,true,'orphan remains unregistered before hire');
const cap=bus.broker.bindLaunch({business:'demo',role:'chief',run:'orphan-hire-chief',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});
+5 -5
View File
@@ -6,10 +6,10 @@ import { createHash, randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { startBroker } from '../src/runtime.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { sessionsFile, SESSIONS_VERSION } from '../../seat/src/session.mjs';
const hash = value => createHash('sha256').update(value).digest('hex');
const deferred = () => {let resolve;const promise=new Promise(r=>{resolve=r;});return {promise,resolve};};
@@ -23,7 +23,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
// Existing row331 scratch aide pattern only; repository role authority unchanged.
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busBusiness(business,resolved);}
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
server=http.createServer(async(req,res)=>{try{
+5 -5
View File
@@ -13,10 +13,10 @@ import { startBroker } from '../src/runtime.mjs';
import { Credentials } from '../src/credentials.mjs';
import { ModelKeys } from '../src/model-keys.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../organization/tests/helpers.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { Registry, SESSIONS_VERSION, sessionsFile } from '../../seat/src/session.mjs';
import { createLauncher } from '../../cli/src/launcher.mjs';
@@ -97,7 +97,7 @@ async function fixture(t, { overdue = false, exited = false } = {}) {
writeSessions([entry]);
if (exited) await exit();
const boot = async () => {
bus = await startBroker({ dataRoot, businesses: { demo: busBusiness(business, resolved) }, organization: { dir: configDir, rolesDir, system },
bus = await startBroker({ dataRoot, businesses: { demo: busOrganization(business, resolved) }, organization: { dir: configDir, rolesDir, system },
proxy: { port: server.address().port, binary: '/fixture-unused' } });
return bus;
};
+5 -5
View File
@@ -12,10 +12,10 @@ import { startBroker } from '../src/runtime.mjs';
import { Credentials } from '../src/credentials.mjs';
import { ModelKeys } from '../src/model-keys.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../organization/tests/helpers.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { Registry, SESSIONS_VERSION, sessionsFile } from '../../seat/src/session.mjs';
import { createLauncher } from '../../cli/src/launcher.mjs';
@@ -116,7 +116,7 @@ export async function reviewFixture(t, { overdue = false, exited = false } = {})
writeSessions([entry]);
if (exited) await exit();
const boot = async ({ proxyMode = true } = {}) => {
bus = await startBroker({ dataRoot, businesses: { demo: busBusiness(business, resolved) }, organization: { dir: configDir, rolesDir, system },
bus = await startBroker({ dataRoot, businesses: { demo: busOrganization(business, resolved) }, organization: { dir: configDir, rolesDir, system },
proxy: proxyMode ? { port: server.address().port, binary: '/fixture-unused' } : null });
return bus;
};
@@ -12,11 +12,11 @@ import { Credentials } from '../src/credentials.mjs';
import { ModelKeys } from '../src/model-keys.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { startBroker } from '../src/runtime.mjs';
import { busBusiness } from '../src/business.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { sessionsFile, SESSIONS_VERSION, Registry } from '../../seat/src/session.mjs';
export const id = value => createHash('sha256').update(value).digest('hex').slice(0,12);
@@ -53,7 +53,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
const rolesDir=rolesCopy(root),system=systemFor(root),doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('aide');
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
const business=loadBusiness('demo',{dir:configDir,rolesDir}),resolved={};for(const name of Object.keys(business.roles))resolved[name]=resolveInstance({system,business,instance:name});
boot={dataRoot,businesses:{demo:busBusiness(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
boot={dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));args.find(a=>typeof a==='function')?.();return true;}return stderr.call(this,chunk,...args);};
const openBus=async()=>{bus=await startBroker(boot);const ownedBus=bus,run='tmp-chief-'+(++busBoots);let closed=false,bound=false;currentBusClose=async()=>{if(closed)return;closed=true;const errors=[];if(bound){try{ownedBus.endLaunch({business:'demo',run,reason:'host-lost',exitCode:null});}catch{errors.push('owned-chief-end');}}try{await ownedBus.close();}catch{errors.push('owned-bus-close');}if(errors.length)throw new AggregateError(errors.map(code=>Error(code)),'owned bus cleanup failed');};closers.push(currentBusClose);c=bus.credentials;chief=bus.broker.bindLaunch({business:'demo',role:'chief',run,harness:'pi'});bound=true;bus.broker.request(chief,{verb:'role.claim'});human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});};
await openBus();boot.openBus=openBus;
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { fixture, git } from './spec-fixtures.mjs';
import { readSpec } from '../../project/src/index.mjs';
import { approveNorthStarDraft, isTemplateNorthStar, loadInterviewTemplate, SPEC_PATH } from '../../project/src/onboarding.mjs';
// F2 (REQ-SETUP-2): the north star through the bus. The assistant proposes with the record.write grant
// the shipped roles/assistant.json already gives it; only the human's project.file.save writes the spec.
// The assistant runs under the shipped authority and messaging, so this is the grant the stack runs.
const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const within = { authority: { withinRole: ['message.send'], crossRole: [] } };
const { drafts } = loadInterviewTemplate();
async function setup(t) {
const f = await fixture(t, { roles: {
pm: { definition: 'pm', ...within }, cto: { definition: 'cto', ...within },
assistant: { definition: 'assistant', authority: shipped('assistant').authority },
} });
f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } };
const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id;
const thread = f.call(f.human, 'thread.create', { project, name: 'Onboarding interview', instance: 'assistant' }).id;
f.assistant = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread });
f.broker.request(f.assistant, { verb: 'role.claim' });
f.notices = () => f.store.all("SELECT to_role, body FROM messages WHERE from_role='_bus' ORDER BY seq");
f.approver = {
read: args => f.call(f.human, 'project.file', args),
save: args => f.call(f.human, 'project.file.save', args),
};
return f;
}
const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code);
test('the assistant cannot write the north star by any verb; it proposes a draft doc instead', async t => {
const f = await setup(t);
await assert.rejects(f.call(f.assistant, 'record.write', { project: 'stack', path: SPEC_PATH, record: '# Mine\n' }), { code: 'project-spec-human-only' });
await assert.rejects(f.call(f.assistant, 'project.file.save', { project: 'stack', path: SPEC_PATH, base: f.commit, content: '# Mine\n' }), { code: 'human-required' });
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), f.commit);
assert.ok(refusals(f).includes('project-spec-human-only') && refusals(f).includes('human-required'));
const draft = await f.call(f.assistant, 'record.write', { project: 'stack', path: drafts.northStar, record: '# North star\n\nOnboard new organizations.\n' });
assert.equal(git(f.repo, 'show', '-s', '--format=%an', draft.commit), 'assistant.assistant');
// The draft alone changes nothing: the spec is still the template, unconfirmed, and no notice went out.
const spec = await f.call(f.human, 'project.file', { project: 'stack', path: SPEC_PATH });
assert.equal(spec.commit, f.commit); assert.equal(isTemplateNorthStar(spec.content), true);
assert.deepEqual(f.notices(), []);
});
test('the human approval copies the reviewed draft into the spec as the human, confirms it and notifies pm and cto', async t => {
const f = await setup(t);
const text = '# North star\n\nOnboard new organizations in one sitting.\n';
const draft = await f.call(f.assistant, 'record.write', { project: 'stack', path: drafts.northStar, record: text });
const spec = await f.call(f.human, 'project.file', { project: 'stack', path: SPEC_PATH });
const done = await approveNorthStarDraft({ ...f.approver, project: 'stack', draft: draft.commit, base: spec.commit, drafts });
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), done.commit);
assert.equal(git(f.repo, 'show', '-s', '--format=%an|%s', done.commit), 'jason|Edit spec/north-star.md');
const after = await readSpec({ business: f.business, project: 'stack', dataRoot: f.root });
assert.equal(after.content, text); assert.equal(after.confirmed, true); assert.equal(after.commit, done.commit);
const sent = f.notices();
assert.deepEqual(sent.map(n => n.to_role).sort(), ['cto', 'pm']);
for (const n of sent) assert.ok(n.body.includes(`commit ${done.commit}, from the editor's base ${spec.commit}`));
});
test('an approval of a draft the assistant rewrote, or over a spec that moved, writes nothing and sends no notice', async t => {
const f = await setup(t);
const first = await f.call(f.assistant, 'record.write', { project: 'stack', path: drafts.northStar, record: '# First\n' });
const spec = await f.call(f.human, 'project.file', { project: 'stack', path: SPEC_PATH });
const second = await f.call(f.assistant, 'record.write', { project: 'stack', path: drafts.northStar, record: '# Second\n' });
await assert.rejects(approveNorthStarDraft({ ...f.approver, project: 'stack', draft: first.commit, base: spec.commit, drafts }), { code: 'draft-stale' });
const moved = await f.call(f.human, 'project.file.save', { project: 'stack', path: SPEC_PATH, base: spec.commit, content: '# Typed by hand\n' });
const head = git(f.repo, 'rev-parse', 'HEAD'), notices = f.notices().length;
await assert.rejects(approveNorthStarDraft({ ...f.approver, project: 'stack', draft: second.commit, base: spec.commit, drafts }), { code: 'stale-base', stage: 'save' });
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), head);
assert.equal(f.notices().length, notices);
assert.equal(git(f.repo, 'show', `HEAD:${SPEC_PATH}`), '# Typed by hand');
assert.equal(moved.commit, head);
});
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { busOrganization } from '../src/organization-context.mjs';
import { busBusiness } from '../src/business.mjs';
import * as bus from '../src/index.mjs';
test('S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input', () => {
const business = {
id: 'demo',
@@ -19,10 +21,16 @@ test('S1 adapter takes resolved limits and refs, rejects mismatched instance, ne
},
};
const before = structuredClone(business),
b = busBusiness(business, resolved);
b = busOrganization(business, resolved);
assert.deepEqual(b.roles.coder.authority, resolved.coder.limits.authority);
assert.deepEqual(b.roles.coder.credentials, {});
assert.deepEqual(business, before);
resolved.coder.instance = 'pm';
assert.throws(() => busBusiness(business, resolved), /invalid-resolution/);
assert.throws(() => busOrganization(business, resolved), /invalid-resolution/);
});
test('rename design r2 stage B: the old module and the package root give the same adapter function', () => {
assert.equal(typeof busOrganization, 'function');
assert.equal(busBusiness, busOrganization);
assert.equal(bus.busBusiness, busOrganization);
assert.equal(bus.busOrganization, busOrganization);
});
@@ -7,8 +7,8 @@ import { Store } from '../src/store.mjs';
import { createLimits, modelUsageKey } from '../src/limits.mjs';
import * as token from '../../plugins/limit-check/token-limit/index.mjs';
import * as daily from '../../plugins/limit-check/daily-quota/index.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { businessDoc, rolesCopy, writeJson, systemFor } from '../../business/tests/helpers.mjs';
import { launchSettings } from '../../organization/src/launch-settings.mjs';
import { businessDoc, rolesCopy, writeJson, systemFor } from '../../organization/tests/helpers.mjs';
const key='abcdefabcdef',other='012345abcdef',since='2026-01-01T00:00:00.000Z';
const quota={limit:10,since,reset:'00:00',timeZone:'UTC'};
const native={at:'2026-10-26T12:00:00.000Z',run:'legacy-run',turn:'turn',account:key,modelKeyId:other,status:'known',input:10,output:0,cacheWrite:0,cacheRead:0};
@@ -56,7 +56,7 @@ test('P5 R1 legacy hex account chief resets stay allowed; daily model old/new/re
});
test('P5 R1 daily model matching/report is separate from legacy account and schema refuses partial or invalid daily shapes',async()=>{
const {checkTokenLimits}=await import('../../business/src/launch.mjs');
const {checkTokenLimits}=await import('../../organization/src/launch.mjs');
assert.deepEqual(checkTokenLimits({keys:{[key]:quota}}).keys[key],quota);
const args={limits:{tokens:{keys:{[key]:quota}}},records:[native],subject:{account:key,modelKeyId:other},now:native.at,lineages:[[key],[other]]};
assert.deepEqual(token.report(args),[]);
+2 -2
View File
@@ -15,8 +15,8 @@ import { ModelKeys, MAX_PREVIOUS_KEY_IDS } from '../src/model-keys.mjs';
import { Store } from '../src/store.mjs';
import { createLimits, modelUsageKey, modelKeyLineages, modelKeyLineage } from '../src/limits.mjs';
import { createUsage } from '../src/usage.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { launchSettings } from '../../organization/src/launch-settings.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const hash = s => createHash('sha256').update(s).digest('hex').slice(0, 12);
const START = Date.parse('2026-10-24T12:00:00.000Z');
+1 -1
View File
@@ -9,7 +9,7 @@ import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Registry } from '../../seat/src/session.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const hash=s=>createHash('sha256').update(s).digest('hex').slice(0,12);
test('P5 real runtime binds stack usage to issued key, daily stops at stack count and human-only telemetry settings pass broker contract',async t=>{
const root=mkdtempSync(join(tmpdir(),'p5-runtime-')),config=join(root,'config'),data=join(root,'data');
+1 -1
View File
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Registry } from '../../seat/src/session.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
// Row 333 R3 (#1831): every wait races a real-timer deadline, captured here before any test mocks
// setTimeout, so a missed event fails by name instead of hanging the file. R4: the real setImmediate
// too, for the yield after a poll, which now runs inside that poll's deadline.
+3 -3
View File
@@ -32,7 +32,7 @@ test('plugins and plugins.set refuse every non-human session and a forged actor,
test('runtime: the switch changes the organization file, the receipt carries the actor, and a bad shape, an unknown module and a stale revision are refused with nothing written', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'plugins-runtime-'));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
@@ -67,7 +67,7 @@ test('runtime: the switch changes the organization file, the receipt carries the
test('vikunja:19/309 through the broker, a patch switches the kinds it names and keeps the rest; null removes one kind and is trailed', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'plugins-merge-'));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { 'usage-source': ['meter'] } });
@@ -93,7 +93,7 @@ test('vikunja:19/309 through the broker, a patch switches the kinds it names and
test('plugins read advertises loadable harnesses even when switched off', async t => {
const {startBroker}=await import('../src/runtime.mjs');
const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');
const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');
const root=mkdtempSync(join(tmpdir(),'plugins-available-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const rolesDir=rolesCopy(root);const doc=businessDoc(root,'demo');doc.plugins={harness:['claude-code']};
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
+2 -2
View File
@@ -39,12 +39,12 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';",
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../../organization/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/);
});
@@ -5,7 +5,7 @@ import {join} from 'node:path';
import {tmpdir} from 'node:os';
import {DatabaseSync} from 'node:sqlite';
import {startBroker} from '../src/runtime.mjs';
import {businessDoc,rolesCopy,systemFor,writeJson} from '../../business/tests/helpers.mjs';
import {businessDoc,rolesCopy,systemFor,writeJson} from '../../organization/tests/helpers.mjs';
test('role must not materialize a new catalogue key by submitting legacy entry shape',async t=>{
const root=fs.mkdtempSync(join(tmpdir(),'materialize-'));t.after(()=>fs.rmSync(root,{recursive:true,force:true}));
const rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=join(root,'businesses/demo.json');
@@ -5,7 +5,7 @@ import {join} from 'node:path';
import {tmpdir} from 'node:os';
import {DatabaseSync} from 'node:sqlite';
import {startBroker} from '../src/runtime.mjs';
import {businessDoc,rolesCopy,systemFor,writeJson} from '../../business/tests/helpers.mjs';
import {businessDoc,rolesCopy,systemFor,writeJson} from '../../organization/tests/helpers.mjs';
const authority=JSON.parse(fs.readFileSync('./roles/chief-of-staff.json','utf8')).authority;
const oldEntry={provider:'zai',model:'fixture-old'};
const newEntry={provider:'zai',model:'fixture-new'};
+2 -2
View File
@@ -219,7 +219,7 @@ test('publishRefused files the code on the task\'s trail and drops the ticket',
test('git.push.remote is in neither ROUTED list and no shipped role grants it', async () => {
const { ACTIONS, HARD_WAITS } = await import('../src/broker.mjs');
const vocabulary = await import('../../business/src/vocabulary.mjs');
const vocabulary = await import('../../organization/src/vocabulary.mjs');
assert.ok(ACTIONS.includes('git.push.remote') && HARD_WAITS.includes('git.push.remote'));
assert.ok(!vocabulary.ROUTED.includes('git.push.remote'));
const { readdirSync, readFileSync } = await import('node:fs');
@@ -232,7 +232,7 @@ test('git.push.remote is in neither ROUTED list and no shipped role grants it',
// token is used only under an open publish ticket, keyed from the agent checkPublish checked.
test('runtime: withGitea keys from the open ticket only, and the store root is the organization dir', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const { chmodSync, mkdirSync } = await import('node:fs');
const root = mkdtempSync(join(tmpdir(), 'bus-publish-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
+5 -5
View File
@@ -7,12 +7,12 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { busBusiness } from '../src/business.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
// The bus form of the business file, built as cli bootConfig builds it.
@@ -20,7 +20,7 @@ function busForm(root, rolesDir, id = 'demo') {
const b = loadBusiness(id, { dir: root, rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
return busBusiness(b, resolved);
return busOrganization(b, resolved);
}
async function setup(t, mutate) {
@@ -13,9 +13,9 @@ import { prepareBootstrap } from '../src/request-bootstrap.mjs';
import { hostPreparation, staffFor } from '../src/request-host.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { managedState } from '../../business/src/project-bootstrap.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { managedState } from '../../organization/src/project-bootstrap.mjs';
import { workingRepo } from '../../seat/src/working.mjs';
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
+4 -4
View File
@@ -101,7 +101,7 @@ test('row 65 runtime: harnessLevels answers each module in the tree, and null fo
test('row 65 runtime: routes and routes.set reach the organization file with the human as actor, a new bind reads plugins.harness, and each refusal keeps the bytes and logs nothing', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = scratch(t, 'routes-runtime-');
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
@@ -150,7 +150,7 @@ test('row 65 runtime: routes and routes.set reach the organization file with the
});
test('fixture provider catalogue appears in actual routes and materializes the selected model', async t => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = scratch(t, 'routes-catalogue-'), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { ...doc.plugins, harness: ['pi'], 'model-provider': ['cliproxy'] };
for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: Object.keys(doc.launch.models)[0], thinking: 'high', ...r.vars };
@@ -170,7 +170,7 @@ test('fixture provider catalogue appears in actual routes and materializes the s
for (const writer of ['limits.set','organization.set','instances.set']) test(`${writer} selects only available compatible existing provider models`, async t => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root=scratch(t,'provider-writer-'),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');
doc.plugins={...doc.plugins,harness:['pi','claude-code'],'model-provider':['cliproxy']};
doc.launch.models['fixture-persisted']={provider:'zai',model:'fixture-id',module:'cliproxy',upstream:'zai'};
@@ -192,7 +192,7 @@ for (const writer of ['limits.set','organization.set','instances.set']) test(`${
});
test('legacy routes follow enabled module mappings while unchanged unavailable selections remain saveable', async t => {
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');
const root=scratch(t,'legacy-catalogue-'),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');
doc.plugins={...doc.plugins,harness:['pi','claude-code'],'model-provider':['cliproxy']};
doc.launch.models['legacy-one']={provider:'anthropic',model:'fixture-one'};doc.launch.models['legacy-two']={provider:'zai',model:'fixture-two'};
+2 -2
View File
@@ -9,7 +9,7 @@ import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker, TESTER_VERBS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
// Row 177 (#1675): thread.list left this list; the tester's lists its own threads (tester-threads.test.mjs).
@@ -104,7 +104,7 @@ test('row 175: W2 as the tester refuses credentials, protected definitions, huma
[{ instance: 'thread-2', op: 'add', definition: 'assistant' }, 'organization-definition-refused'],
[{ instance: 'human', op: 'add', definition: 'merge-gate' }, 'invalid-request'],
[{ instance: 'tester', op: 'add', definition: 'merge-gate' }, 'invalid-request'],
// bus.tester is a business-only var (business/src/vars.mjs), so no instance carries it.
// bus.tester is a business-only var (organization/src/vars.mjs), so no instance carries it.
[{ instance: 'gate-2', op: 'add', definition: 'merge-gate', vars: { 'bus.tester': true } }, 'invalid-request'],
[{ instance: 'coder', op: 'change', vars: { 'bus.tester': true } }, 'invalid-request'],
];
+4 -4
View File
@@ -9,7 +9,7 @@ import { Broker, BusError, WEB_UNTRUSTED } from '../src/broker.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { webService, WEB_SEARCH_NOT_CONFIGURED } from '../src/web.mjs';
import { busBusiness } from '../src/business.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { WEB_REFUSAL, WebRefusal } from '../../discord/src/web.mjs';
// REQ-HARN-2: the web verbs on the broker. The search source and the network are fakes: the real
// SearXNG answers 403 for JSON until vikunja:19/291 is fixed, and no test touches the network.
@@ -272,11 +272,11 @@ test('over the socket: the server routes web verbs to the service; other roles a
await assert.rejects(new Client({ path: path2, cap: broker.bindLaunch({ business: 'demo', role: 'pm', run: 'run-pm2', harness: 'pi' }) }).call('web.search', { query: 'x' }), { code: 'not-holder' });
});
test('busBusiness: the web grant comes from "web" in the resolved tool limit', () => {
test('busOrganization: the web grant comes from "web" in the resolved tool limit', () => {
const resolved = (tools) => ({ business: 'demo', instance: 'a', definition: 'a', limits: { authority: { withinRole: [], crossRole: [] }, tools } });
const base = { id: 'demo', roles: { a: { definition: 'a' } }, human: 'jason' };
assert.equal(busBusiness(base, { a: resolved(['read', 'web']) }).roles.a.web, true);
assert.equal(busBusiness(base, { a: resolved(['read']) }).roles.a.web, false);
assert.equal(busOrganization(base, { a: resolved(['read', 'web']) }).roles.a.web, true);
assert.equal(busOrganization(base, { a: resolved(['read']) }).roles.a.web, false);
});
test('webServices: the business\'s search-source plug-in and web.searxng var make web.search work; without either it is refused as not configured', async (t) => {
+4 -4
View File
@@ -9,9 +9,9 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { busBusiness } from '../src/business.mjs';
import { resolveInstance, validateBusinessDocument } from '../../business/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
const TASK = 'vikunja:3/7';
const A = 'a'.repeat(40);
@@ -31,7 +31,7 @@ function setup(t, mutate = () => {}) {
const resolved = {};
for (const instance of Object.keys(business.roles))
resolved[instance] = resolveInstance({ system: systemFor(root), business, instance });
const config = busBusiness(business, resolved);
const config = busOrganization(business, resolved);
mutate(config);
const store = new Store(root);
const b = new Broker({ store, businesses: { demo: config } });
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@mosaic/business",
"version": "0.1.0",
"private": true,
"description": "Role definitions, business and project files, variable layers and the resolver (slice 1 row S1).",
"description": "Old name of @mosaic/organization (packages/organization). Every module here re-exports the moved implementation; nothing is duplicated.",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
+2 -174
View File
@@ -1,174 +1,2 @@
// The access file, <configDir>/access.json (federation design section 3.5,
// FED R1, #414). It names the extra users who may sign in to this instance
// and the grants that let them reach a business's threads. The validator is
// pure: providers, peers, businesses and the owner's sign-in subjects come in
// as arguments. Any failed rule refuses the whole file, which leaves every
// user with no grant (fail closed).
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { configDir } from "./business.mjs";
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
// FED R3 r5 (row 274): LEVELS, the all marker and covers() live in the vocabulary, which the session
// sandbox mounts, because the broker checks a user's grant with covers(). This file re-exports them.
import { LEVELS, GRANT_ALL as ALL, covers } from "./vocabulary.mjs";
export { LEVELS, covers };
// Ids a user may never take: the bus's own principals.
const RESERVED_USERS = Object.freeze(["human", "tester"]);
const LOCAL_HOME = "local";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
const SUB_MAX = 255;
export function accessFilePath(dir = configDir()) {
return join(dir, "access.json");
}
function requireArray(value, where) {
if (!Array.isArray(value)) refuse(`${where} must be an array`);
return value;
}
function requireText(value, where) {
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
}
return value;
}
function subjectKey(provider, sub) {
return JSON.stringify([provider, sub]);
}
function checkContext(context) {
const where = "access context";
requireObject(context, where);
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
requireObject(context.businesses, `${where}.businesses`);
const businesses = new Map();
for (const [id, business] of Object.entries(context.businesses)) {
const at = `${where}.businesses.${id}`;
requireId(id, `${where}.businesses key`);
requireObject(business, at);
requireId(business.human, `${at}.human`);
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
businesses.set(id, { human: business.human, instances: new Set(instances) });
}
const owners = new Set();
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
const at = `${where}.ssoSubjects[${i}]`;
requireObject(s, at);
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
});
return { providers, peers, businesses, owners };
}
function checkUser(user, at, ctx, humans, seenSubjects) {
requireObject(user, at);
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
requireId(user.id, `${at}.id`);
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
}
const subjects = requireArray(user.subjects, `${at}.subjects`);
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
subjects.forEach((s, i) => {
const sat = `${at}.subjects[${i}]`;
requireObject(s, sat);
rejectUnknownKeys(s, ["provider", "sub"], sat);
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
}
requireText(s.sub, `${sat}.sub`);
const key = subjectKey(s.provider, s.sub);
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
seenSubjects.add(key);
});
}
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
function checkScope(value, where, check) {
const list = requireArray(value, where);
if (list.length === 0) refuse(`${where} must not be empty`);
if (list.includes(ALL)) {
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
return;
}
const seen = new Set();
list.forEach((entry, i) => {
check(entry, `${where}[${i}]`);
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
seen.add(entry);
});
}
function checkExpires(value, where) {
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
}
const iso = new Date(value).toISOString();
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
}
function checkGrant(grant, at, ctx, users, seenGrants) {
requireObject(grant, at);
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
requireId(grant.id, `${at}.id`);
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
seenGrants.add(grant.id);
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
}
const business = ctx.businesses.get(grant.business);
checkScope(grant.projects, `${at}.projects`, (p, where) => {
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
});
checkScope(grant.instances, `${at}.instances`, (x, where) => {
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
});
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
}
// Check a parsed access document against the instance's context. Returns the
// document frozen. Every refusal is a BusinessError naming the key path. An
// expired grant is not refused: covers() treats it as absent.
export function checkAccess(document, context) {
const ctx = checkContext(context);
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
if (!isPlainObject(document)) refuse("access file must be a JSON object");
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
requirePositiveInt(document.revision, "revision");
const users = new Set();
const seenSubjects = new Set();
requireArray(document.users, "users").forEach((user, i) => {
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
users.add(user.id);
});
const seenGrants = new Set();
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
return deepFreeze(structuredClone(document));
}
// Read and check the access file. One descriptor, opened without following a
// link, serves the stat checks and the read (readJsonFile). The file holds
// authority, so it must belong to this uid and not be writable by group or
// other, as the business file is.
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
const document = readJsonFile(file, "access file", (stat) => {
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return checkAccess(document, context);
}
// Old path of packages/organization/src/access.mjs (rename design r2, stage B).
export * from "../../organization/src/access.mjs";
+2 -30
View File
@@ -1,30 +1,2 @@
// The organization file's `appearance` (stream S4, PLAN C5): { theme, mode }, which theme the
// Console shows for this organization and in which mode. Both are optional, and a file without
// `appearance` is valid: the Console then shows its built-in default theme in the system mode.
//
// `theme` is a module name (the plugin name pattern), checked for shape only. Whether a module of
// that name is installed is the Console's question at render time, not the file's: a file that
// names a theme which was since uninstalled stays valid and keeps its choice, and the Console
// shows the default with a warning until the module is back.
import { NAME } from "../../plugins/src/kinds.mjs";
import { refuse } from "./errors.mjs";
import { requireObject, rejectUnknownKeys } from "./util.mjs";
export const APPEARANCE_MODES = Object.freeze(["system", "light", "dim", "dark"]);
// The checked, frozen appearance, or undefined when the file has none.
export function checkAppearance(value, where) {
if (value === undefined) return undefined;
requireObject(value, where);
rejectUnknownKeys(value, ["theme", "mode"], where);
if (Object.hasOwn(value, "theme") && (typeof value.theme !== "string" || value.theme.length > 64 || !NAME.test(value.theme))) {
refuse(`${where}.theme must be an installed theme's name, matching ${NAME} (got ${JSON.stringify(value.theme)})`);
}
if (Object.hasOwn(value, "mode") && !APPEARANCE_MODES.includes(value.mode)) {
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")} (got ${JSON.stringify(value.mode)})`);
}
return Object.freeze({
...(Object.hasOwn(value, "theme") ? { theme: value.theme } : {}),
...(Object.hasOwn(value, "mode") ? { mode: value.mode } : {}),
});
}
// Old path of packages/organization/src/appearance.mjs (rename design r2, stage B).
export * from "../../organization/src/appearance.mjs";
+2 -345
View File
@@ -1,345 +1,2 @@
// The business file, <configDir>/businesses/<id>.json (REQ-ROLE-3, note
// section 2, addendum A sections 7 and 8). Human edits go through the trusted
// organization writer. A missing or invalid file refuses (lead decision 46, 6.1).
import { dirname, isAbsolute, join, normalize } from "node:path";
import { homedir } from "node:os";
import { refuse } from "./errors.mjs";
import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
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 { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { checkAppearance } from "./appearance.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
export function configDir(env = process.env) {
const file = env.MOSAIC_CONFIG || join(homedir(), ".config", "mosaic-dev", "config.json");
return dirname(file);
}
export function businessFilePath(id, dir = configDir()) {
requireId(id, "business id");
return join(dir, "businesses", `${id}.json`);
}
export function checkBot(tracker, where) {
requireObject(tracker, where);
rejectUnknownKeys(tracker, ["bot", "botId"], where);
if (typeof tracker.bot !== "string" || !BOT_NAME.test(tracker.bot)) refuse(`${where}.bot must be a Vikunja bot username starting with "bot-"`);
requirePositiveInt(tracker.botId, `${where}.botId`);
return { bot: tracker.bot, botId: tracker.botId };
}
function checkCredentialMap(map, services, where, store = null) {
requireObject(map, where);
const names = Object.keys(map).sort();
const wanted = [...services].sort();
if (names.join(",") !== wanted.join(",")) {
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
}
const out = {};
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`, { store });
return out;
}
// Row 199 (#1697, design D8): the agent's own git identity. The email is the one its Gitea user
// has, so Gitea links the commits to that user; gitea.url is the instance its pushes go to.
const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/;
const NAME = /^[^\0\n\r<>]{1,100}$/;
// Row 199 (design D9c): the parse is vocabulary.mjs's, where the broker reads it; this wraps it with
// the loader's refusals. Returns {url, owner, repo}.
export function httpsUrl(value, where, { repo = false } = {}) {
const parsed = parseGiteaUrl(value, { repo });
if (parsed.problem === "form") refuse(`${where} must be a normalized https URL with no user, query or fragment`);
if (parsed.problem === "instance") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`);
if (parsed.problem === "repository") refuse(`${where} must name a repository, https://HOST/OWNER/REPO`);
return parsed;
}
function checkIdentity(identity, where) {
requireObject(identity, where);
rejectUnknownKeys(identity, ["name", "email", "gitea"], where);
if (typeof identity.name !== "string" || !NAME.test(identity.name)) refuse(`${where}.name must be 1 to 100 characters with no <, > or line break`);
if (typeof identity.email !== "string" || !EMAIL.test(identity.email)) refuse(`${where}.email must be an email address`);
requireObject(identity.gitea, `${where}.gitea`);
rejectUnknownKeys(identity.gitea, ["url", "user"], `${where}.gitea`);
httpsUrl(identity.gitea.url, `${where}.gitea.url`);
if (!giteaName(identity.gitea.user)) refuse(`${where}.gitea.user must be a Gitea user name`);
return { name: identity.name, email: identity.email, gitea: { url: identity.gitea.url, user: identity.gitea.user } };
}
function checkTracker(tracker, file) {
const where = `${file} tracker`;
requireObject(tracker, where);
rejectUnknownKeys(tracker, ["sync", "labels"], where);
requireObject(tracker.sync, `${where}.sync`);
rejectUnknownKeys(tracker.sync, ["bot", "botId", "credentials"], `${where}.sync`);
const sync = {
...checkBot({ bot: tracker.sync.bot, botId: tracker.sync.botId }, `${where}.sync`),
credentials: checkCredentialMap(tracker.sync.credentials, ["vikunja"], `${where}.sync.credentials`),
};
let labels = {};
if (tracker.labels !== undefined) {
requireObject(tracker.labels, `${where}.labels`);
// fromEntries defines own properties, so a "__proto__" title stays data.
labels = Object.fromEntries(Object.entries(tracker.labels).map(([title, id]) => [
requireString(title, `${where}.labels title`, { max: 100 }),
requirePositiveInt(id, `${where}.labels.${title}`),
]));
}
return { sync, labels };
}
// Row 213 (C2-08, Case 2 design 4): persona vars belong only to a creative instance, and come as a
// pair, since the bundle reads the file only against its digest.
function checkPersonaVars(vars, definition, at) {
const set = ["persona.name", "persona.sha256"].filter((key) => Object.hasOwn(vars, key));
if (set.length === 0) return;
if (!CREATIVE_DEFINITIONS.includes(definition)) refuse(`${at}.vars: persona vars need a creative definition (${definition} isn't one)`);
if (set.length === 1) refuse(`${at}.vars: persona.name and persona.sha256 are set together or not at all`);
}
// Row 234 (#1740, r6 S1): hosts and host grants are bus store records made by a human-approved grant,
// so the business file can't name them. The key stays known so the refusal says where they went.
const HOSTS_MOVED = "lives in the bus store now: enroll a host with a host-enroll grant (mosaic hosts status)";
function checkRoles(roles, file, rolesDir, business, hasLibrary) {
const where = `${file} roles`;
requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
const out = {};
const definitions = {};
const bots = new Set();
const botIds = new Set();
for (const [instance, entry] of Object.entries(roles)) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
const services = definition.credentials.map((c) => c.service);
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
// Row 285 amendment 1 (mos-1580): the chief needs its read reference only for a library.
// Other definitions keep the exact service list; checkCredentialMap itself is unchanged.
const kinds = services.filter((s) => s !== HOST_SERVICE && !(entry.definition === "chief-of-staff" && !hasLibrary && s === "gitea"));
if (entry.definition === "chief-of-staff" && hasLibrary && !Object.hasOwn(entry.credentials ?? {}, "gitea")) {
refuse(`chief-library-gitea-required: ${at}.credentials must reference gitea because this business declares a library`);
}
let tracker = null;
if (services.includes("vikunja")) {
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
tracker = checkBot(entry.tracker, `${at}.tracker`);
if (bots.has(tracker.bot)) refuse(`${at}.tracker.bot ${tracker.bot} is used by another instance`);
if (botIds.has(tracker.botId)) refuse(`${at}.tracker.botId ${tracker.botId} is used by another instance`);
bots.add(tracker.bot);
botIds.add(tracker.botId);
} else if (entry.tracker !== undefined) {
refuse(`${at} has "tracker" but ${entry.definition} uses no vikunja credential`);
}
out[instance] = {
definition: entry.definition,
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
tracker,
credentials: checkCredentialMap(entry.credentials ?? {}, kinds, `${at}.credentials`,
{ configDir: dirname(dirname(file)), business, agent: instance }),
};
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
// names are checked against projects once those are read.
if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`);
if (entry.publish !== undefined) {
// N10: one message for both, naming the grant.
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
}
checkPersonaVars(out[instance].vars, entry.definition, at);
}
return { roles: out, definitions };
}
function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire", "grantScope"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
if (!launcher.authority.withinRole.includes("role.launch")) {
refuse(`${where}.by names ${launch.by}, whose role ${launcher.name} doesn't hold role.launch within-role`);
}
const instances = requireDistinctList(launch.instances, `${where}.instances`, (name) => {
requireId(name, `${where}.instances entry`);
if (!Object.hasOwn(roles, name)) refuse(`${where}.instances names ${name}, which the business file doesn't declare`);
if (name === launch.by) refuse(`${where}.instances can't include the launcher itself (${name})`);
}, { nonEmpty: true });
const models=checkModels(launch.models,`${where}.models`);
const limits=checkLimits(launch.limits===undefined?{}:launch.limits,`${where}.limits`);
for(const instance of Object.keys(limits.tokens?.instances??{}))if(!Object.hasOwn(roles,instance))refuse(`${where}.limits.tokens.instances names ${instance}, which the business file doesn't declare`);
// Row 126 (#1620): a daily quota for any account but launch.account would count nothing and still
// show as counted, so it refuses the read.
const account = launch.account === undefined ? undefined : checkAccount(launch.account, `${where}.account`);
for (const name of Object.keys(limits.tokens?.daily ?? {})) {
if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`);
}
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
// No upper bound: limits have no ceiling in code (Q150).
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
// chief can't fire, replace or end. noHire names definitions the chief can't hire, by the id
// pattern only, so a stale entry is harmless. Both are optional and only the human writes them.
const keep = launch.keep === undefined ? undefined : requireDistinctList(launch.keep, `${where}.keep`, (name) => {
requireId(name, `${where}.keep entry`);
if (!Object.hasOwn(roles, name)) refuse(`${where}.keep names ${name}, which the business file doesn't declare`);
if (END_PROTECTED.includes(roles[name].definition)) refuse(`${where}.keep names ${name}, whose definition ${roles[name].definition} can't be kept`);
});
const noHire = launch.noHire === undefined ? undefined
: requireDistinctList(launch.noHire, `${where}.noHire`, (name) => requireId(name, `${where}.noHire entry`));
return {
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
// Row 298 (#1804, Q228): the chief's grant scope passes through unchecked. A bad value must not
// refuse the whole file; the broker's reader (grantScopeOf) falls back to the empty scope instead.
...(launch.grantScope !== undefined ? { grantScope: structuredClone(launch.grantScope) } : {}),
};
}
// Row 194 (#1692, V5): instance names a delete retired. None may be declared again, so a later
// create or hire can't reuse a name whose decisions outlive it.
function checkRetired(retired, roles, file) {
return requireDistinctList(retired, `${file} retired`, (name) => {
requireId(name, `${file} retired entry`);
if (Object.hasOwn(roles, name)) refuse(`${file} retired names ${name}, which roles still declares`);
});
}
// Validate a parsed business document read from `file`. `rolesDir` is where
// role definitions live. Returns a frozen business with its definitions.
export function validateBusinessDocument(document, file, { rolesDir }) {
requireObject(document, "business file");
rejectUnknownKeys(document, TOP_KEYS, "business file");
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
requireId(document.id, "business id");
const base = file.split("/").pop().replace(/\.json$/, "");
if (document.id !== base) refuse(`business "id" (${document.id}) must match its filename (${base}.json)`);
for (const key of ["human", "arbiters", "projects", "roles"]) {
if (document[key] === undefined) refuse(`business file requires "${key}" (${file})`);
}
const human = requireId(document.human, "business human");
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
if (document.hosts !== undefined) refuse(`${file} hosts ${HOSTS_MOVED}`);
const hasLibrary = Object.values(document.projects ?? {}).some((project) => project?.kind === "library");
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary);
requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
const arbiters = {};
for (const kind of ["delivery", "technical"]) {
const name = document.arbiters[kind];
requireId(name, `${file} arbiters.${kind}`);
if (!Object.hasOwn(roles, name)) refuse(`${file} arbiters.${kind} names ${name}, which the business file doesn't declare`);
// Row 133 (#1627): an arbiter messages the raiser when it closes a decision, so it can't be a
// role whose definition limits who it sends to and hears from.
const definition = roles[name].definition;
if (definitions[definition].messaging) {
refuse(`${file} arbiters.${kind} names ${name}, whose role definition ${definition} declares messaging lists`);
}
arbiters[kind] = name;
}
requireObject(document.projects, `${file} projects`);
if (Object.keys(document.projects).length === 0) refuse(`${file} projects must name at least one project`);
const projects = {};
for (const [id, entry] of Object.entries(document.projects)) {
requireId(id, `${file} project id`);
requireObject(entry, `${file} projects.${id}`);
if (entry.kind === 'library') {
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
continue;
}
rejectUnknownKeys(entry, ["root", "repo", "remote", "git"], `${file} projects.${id}`);
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`);
}
if (Object.hasOwn(entry, "repo") && (typeof entry.repo !== "string" || !isAbsolute(entry.repo) ||
normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) {
refuse(`${file} projects.${id}.repo must be a normalized absolute path`);
}
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
if (Object.hasOwn(entry, "git")) {
requireObject(entry.git, `${file} projects.${id}.git`);
rejectUnknownKeys(entry.git, ["mode", "remote"], `${file} projects.${id}.git`);
if (!['local-only', 'remote'].includes(entry.git.mode)) refuse(`${file} projects.${id}.git.mode must be local-only or remote`);
if (entry.git.mode === 'remote') httpsUrl(entry.git.remote, `${file} projects.${id}.git.remote`, { repo: true });
else if (Object.hasOwn(entry.git, 'remote')) refuse(`${file} projects.${id}.git.remote is not allowed for local-only`);
}
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}),
...(Object.hasOwn(entry, "git") ? { git: { ...entry.git } } : {}) };
}
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.publish ?? []) {
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
// C2-21, sage-2916: retain the fence; only creative instances and the chief may hold library reach.
if (projects[project].kind === 'library' && !CREATIVE_DEFINITIONS.includes(role.definition) && role.definition !== 'chief-of-staff')
refuse(`${file} roles.${instance}.publish names library project ${project}; library publish requires a creative definition or chief-of-staff with gitea`);
if (!projects[project].remote) refuse(`${file} roles.${instance}.publish names ${project}, which has no "remote"`);
const problem = forkProblem(projects[project].remote, role.identity);
if (problem) refuse(`${file} roles.${instance}.publish names ${project}: ${problem}`);
}
}
const tracker = Object.hasOwn(document, "tracker") ? checkTracker(document.tracker, file) : null;
for (const [instance, role] of Object.entries(roles)) {
if (tracker && role.tracker && (role.tracker.bot === tracker.sync.bot || role.tracker.botId === tracker.sync.botId)) {
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
}
}
const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
// REQ-PLUG-1: the module names switched on per kind. The names are checked
// 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) }));
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);
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
});
}
// Load businesses/<id>.json from the config directory. The file holds
// authority, so it must belong to this user and not be writable by group
// or other.
export function loadBusiness(id, { dir = configDir(), rolesDir }) {
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
const file = businessFilePath(id, dir);
const document = readJsonFile(file, "business file", (stat) => {
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return validateBusinessDocument(document, file, { rolesDir });
}
// Old path of packages/organization/src/organization-config.mjs (rename design r2, stage B).
export * from "../../organization/src/organization-config.mjs";
+4 -138
View File
@@ -1,139 +1,5 @@
#!/usr/bin/env node
// mosaic business validate <business>
// mosaic business resolve <business> <instance> [--project <id>]
//
// validate loads the business file, every role definition it uses, the
// project files that exist under its declared roots, and stat-checks every
// credential reference. resolve prints one role instance's resolved record.
// Both print JSON on stdout and problems on stderr.
//
// Exit codes: 0 ok; 2 invalid (business, role, project file or a credential
// reference); 3 system config problem; 4 usage, or a required file missing.
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BusinessError } from "./errors.mjs";
import { loadBusiness } from "./business.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { checkCredentialRef } from "./credentials.mjs";
import { systemVars, resolveInstance } from "./resolve.mjs";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const USAGE = "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]";
function fail(code, message) {
process.stderr.write(`mosaic business: ${message}\n`);
process.exit(code);
}
function loadSystem() {
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], {
encoding: "utf8",
maxBuffer: 1024 * 1024,
});
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
try {
return JSON.parse(proc.stdout);
} catch {
fail(3, "system config: mosaic-config printed something that isn't JSON");
}
}
function rolesDir() {
return resolve(process.env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
}
// Check credential references; print warnings; return the problem list.
function checkRefs(refs, forbiddenRoots) {
const problems = [];
const warnings = [];
for (const ref of refs) {
const result = checkCredentialRef(ref, { forbiddenRoots });
problems.push(...result.problems);
warnings.push(...result.warnings);
}
return { problems, warnings };
}
function forbiddenRoots(config, business) {
return [REPO, config.dataRoot, ...Object.values(business.projects).filter(p => p.kind !== 'library').map((p) => p.root)];
}
function validate(args) {
if (args.length !== 1) fail(4, USAGE);
const config = loadSystem();
const system = systemVars(config);
const business = loadBusiness(args[0], { rolesDir: rolesDir() });
const projects = {};
const warnings = [];
for (const [id, entry] of Object.entries(business.projects)) {
if (entry.kind === 'library') { projects[id] = 'library'; continue; }
const file = projectFilePath(entry.root);
if (!existsSync(file)) {
projects[id] = "absent";
warnings.push(`project ${id}: no project file at ${file}; it has no project variables`);
continue;
}
const project = loadProject(entry.root);
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${business.id} declares it as ${id}`);
for (const instance of Object.keys(business.roles)) resolveInstance({ system, business, project, instance });
projects[id] = "valid";
}
const refs = [
...Object.values(business.tracker?.sync?.credentials ?? {}),
...Object.values(business.roles).flatMap((r) => Object.values(r.credentials)),
];
const checked = checkRefs(refs, forbiddenRoots(config, business));
warnings.push(...checked.warnings);
const instances = {};
for (const instance of Object.keys(business.roles)) {
instances[instance] = resolveInstance({ system, business, instance }).digest;
}
for (const w of warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
if (checked.problems.length > 0) {
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
fail(2, `business ${business.id}: ${checked.problems.length} credential reference problem(s)`);
}
process.stdout.write(`${JSON.stringify({ business: business.id, file: business.file, projects, instances }, null, 2)}\n`);
}
function resolveCommand(args) {
let project = null;
const rest = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--project") {
if (!args[i + 1] || project !== null) fail(4, USAGE);
project = args[++i];
} else rest.push(args[i]);
}
if (rest.length !== 2) fail(4, USAGE);
const [businessId, instance] = rest;
const config = loadSystem();
const business = loadBusiness(businessId, { rolesDir: rolesDir() });
let loaded = null;
if (project !== null) {
const entry = Object.hasOwn(business.projects, project) ? business.projects[project] : null;
if (!entry) fail(2, `business ${business.id} declares no project ${JSON.stringify(project)}`);
if (entry.kind !== 'library') loaded = loadProject(entry.root);
}
const resolved = resolveInstance({ system: systemVars(config), business, project: loaded, instance });
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, business));
for (const w of checked.warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
if (checked.problems.length > 0) {
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
fail(2, `${business.id} ${instance}: ${checked.problems.length} credential reference problem(s)`);
}
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
}
const [verb, ...args] = process.argv.slice(2);
try {
if (verb === "validate") validate(args);
else if (verb === "resolve") resolveCommand(args);
else fail(4, USAGE);
} catch (error) {
if (error instanceof BusinessError) fail(error.exitCode, error.message);
throw error;
}
// Old executable path of packages/organization/src/cli.mjs (rename design r2,
// stage B). Importing the moved CLI runs it with this process's arguments, so
// `node packages/business/src/cli.mjs validate ID` behaves exactly as before.
import "../../organization/src/cli.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/config-command.mjs (rename design r2, stage B).
export * from "../../organization/src/config-command.mjs";
+2 -125
View File
@@ -1,125 +1,2 @@
// Credential references (note section 2, addendum A section 7). A
// reference names where a token lives, never the token. The checks here use
// lstat and realpath only; nothing in this package opens a token file.
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, join, normalize, relative, sep } from "node:path";
import { refuse } from "./errors.mjs";
import { credentialKind, isHostKind } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
const WARN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Shape check for one reference. Returns a frozen { service, file | env,
// rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the
// file at <configDir>/secrets/agents/<business>/<agent>/<service>, derived
// here and never written, so an entry can't name another agent's file. A
// reference with no agent (the tracker's sync bot) passes store = null and
// can't use the form. A `file` reference into the agents' store must be the
// agent's own path (`credential-not-yours`).
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
if (!credentialKind(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
// Row 210 (#1707, design D1): an ssh key doesn't expire, so host.<id> takes rotateBy as gitea does.
const dateKey = isHostKind(service) ? "rotateBy" : DATE_KEY[service];
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
const hasStore = ref.store !== undefined;
if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`);
const out = { service };
if (hasStore) {
if (ref.store !== true) refuse(`${where}.store must be true`);
if (!store) refuse(`${where}: a "store" reference needs an agent`);
out.store = true;
out.file = storePath(store, service);
} else if (hasFile) {
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
refuse(`${where}.file must be a normalized absolute path`);
}
out.file = ref.file;
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`);
}
} else {
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
out.env = ref.env;
}
if (ref[dateKey] === undefined) refuse(`${where} needs "${dateKey}" (YYYY-MM-DD)`);
out[dateKey] = requireDate(ref[dateKey], `${where}.${dateKey}`);
return Object.freeze(out);
}
// The store path for one agent and service (design D1).
export function storePath({ configDir, business, agent }, service) {
return join(configDir, "secrets", "agents", business, agent, service);
}
function inside(root, path) {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
}
// Check a parsed reference against the filesystem and the calendar.
// Returns { problems: [...], warnings: [...] }; a caller that finds any
// problem refuses. `forbiddenRoots` are directories a token file must not
// sit in (the repository, dataRoot). `now` is a Date.
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid() } = {}) {
const problems = [];
const warnings = [];
const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`;
let notIssued = false;
if (ref.file) {
let stat = null;
try {
stat = lstatSync(ref.file);
} catch (e) {
// Row 199 (design D3a): a store reference with no file is an agent not issued yet.
notIssued = Boolean(ref.store) && e.code === "ENOENT";
if (notIssued) warnings.push(`${label}: not issued`);
else problems.push(`${label}: not found`);
}
if (stat) {
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
else {
if (stat.uid !== uid) problems.push(`${label}: owned by uid ${stat.uid}, not ${uid}`);
if ((stat.mode & 0o077) !== 0) problems.push(`${label}: mode ${(stat.mode & 0o777).toString(8)} gives group or other access; use 600`);
if (stat.size === 0) problems.push(`${label}: empty`);
let real = ref.file;
try {
real = realpathSync(ref.file);
} catch {
problems.push(`${label}: path can't be resolved`);
}
for (const root of forbiddenRoots) {
let realRoot = root;
try {
realRoot = realpathSync(root);
} catch {
// A root that doesn't exist yet can't contain the file.
}
if (inside(realRoot, real)) problems.push(`${label}: inside ${root}; token files live outside the repository and dataRoot`);
}
}
}
} else if (env[ref.env] === undefined || env[ref.env] === "") {
warnings.push(`${label}: not set in this environment; the launcher must provide it`);
}
// Row 194 (brief item 7, sage-1605 pick a): a human create writes the create day as the date,
// and issue writes the real one. Until then the date describes no token, so a store reference
// with no file skips both date checks. Every other reference keeps them.
if (notIssued) return { problems, warnings };
const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS);
if (ref.expires) {
const left = days(ref.expires);
if (Date.parse(`${ref.expires}T00:00:00Z`) <= now.getTime()) problems.push(`${label}: expired on ${ref.expires}`);
else if (left < WARN_DAYS) warnings.push(`${label}: expires on ${ref.expires}`);
}
if (ref.rotateBy && Date.parse(`${ref.rotateBy}T00:00:00Z`) <= now.getTime()) {
warnings.push(`${label}: rotation was due on ${ref.rotateBy}`);
}
return { problems, warnings };
}
// Old path of packages/organization/src/credentials.mjs (rename design r2, stage B).
export * from "../../organization/src/credentials.mjs";
+2 -13
View File
@@ -1,13 +1,2 @@
// Exit codes follow docs/TOOLS.md: 2 invalid data or refused, 4 usage or a
// missing file. Every refusal in this package is a BusinessError.
export class BusinessError extends Error {
constructor(message, exitCode = 2) {
super(message);
this.name = "BusinessError";
this.exitCode = exitCode;
}
}
export function refuse(message, exitCode = 2) {
throw new BusinessError(message, exitCode);
}
// Old path of packages/organization/src/errors.mjs (rename design r2, stage B).
export * from "../../organization/src/errors.mjs";
+2 -86
View File
@@ -1,86 +1,2 @@
// Row 210 (#1707, design D3 and D6): the address, user and host key checks, and the argv allowlist the
// broker matches each host.run against. Row 234 (#1740, r6 S1) moved the host registry and the grants
// out of the business file into bus store records. The enrollment raise checks its fields with these,
// and row b's host grant raise checks its commands with checkCommands.
import { isIP } from "node:net";
import { refuse } from "./errors.mjs";
import { ARGV_MAX } from "./vocabulary.mjs";
// Row 210 r4 (#1707): the argv check lives in vocabulary.mjs, which the worker sandbox mounts
// and which imports nothing, so the broker can read it. Re-exported so the D6 checks keep one
// business home here.
export { ARGV_MAX, ARG_BYTES, ARGV_BYTES, validArgv, commandAllowed } from "./vocabulary.mjs";
const DNS = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/;
export const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
export const HOST_KEY = /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/]+={0,2}$/;
// D6: a prefix starting with one of these, by basename, would turn the grant back into "any".
// The list can't be complete. Under PRD 1.7 the control is the human's decision on each call (D4).
export const REFUSED_COMMANDS = Object.freeze([
"sh", "bash", "dash", "zsh", "ksh", "fish", "busybox",
"su", "sudo", "doas", "pkexec", "runuser", "env", "xargs", "nohup", "setsid", "timeout", "nice",
"systemd-run", "chroot", "nsenter", "unshare", "setpriv", "capsh", "flock", "stdbuf", "ionice", "chrt",
"taskset", "strace", "script", "watch",
"perl", "ruby", "node", "sed", "vi", "vim", "less",
]);
// Versioned names: python, python3, python3.12; awk, gawk, mawk, nawk; php, php8.2.
const REFUSED_FORMS = Object.freeze([/^python[0-9.]*$/, /^[gmn]?awk$/, /^php[0-9.]*$/]);
export function refusedCommand(first) {
const base = first.split("/").pop();
return REFUSED_COMMANDS.includes(base) || REFUSED_FORMS.some((form) => form.test(base));
}
// D3 step 2: true when the address is this machine. A host grant there would be root on the
// stack's own machine (section 6).
export function ownMachine(address) {
const version = isIP(address);
if (version === 4) {
const first = Number(address.split(".")[0]);
return first === 127 || first === 0;
}
if (version === 6) {
// The URL parser writes the canonical form, so 0:0:0:0:0:0:0:1 reads as [::1] and
// ::ffff:127.0.0.1 as [::ffff:7f00:1].
const canonical = new URL(`http://[${address}]`).hostname;
return canonical === "[::1]" || canonical === "[::]" || /^\[::ffff:(7f[0-9a-f]{2}|[0-9a-f]{1,2}):/.test(canonical);
}
return address === "localhost" || address.endsWith(".localhost");
}
export function checkAddress(address, where) {
if (typeof address !== "string") refuse(`${where} must be an IP address or a DNS name`);
const version = isIP(address);
if (version === 6 && address.includes("%")) refuse(`${where} must not carry an IPv6 zone`);
if (version === 0) {
if (!DNS.test(address) || address.includes("..")) refuse(`${where} must be an IP address or a DNS name`);
// 127.1, 2130706433 and 0177.0.0.1 pass the DNS pattern and the resolver reads them as IPv4.
if (/^[0-9.]+$/.test(address)) refuse(`${where} must be a full four-part IPv4 address`);
if (address.split(".").some((label) => label.startsWith("0x"))) refuse(`${where} must not hold a hex label`);
}
if (ownMachine(address)) refuse(`${where} names this machine (${address})`);
return address;
}
// D6: an argv allowlist and nothing else. No wildcard: "any", "*", {} and [] all refuse.
export function checkCommands(commands, where) {
if (!Array.isArray(commands)) refuse(`${where} must be a list of argv prefixes; there is no wildcard grant`);
if (commands.length === 0) refuse(`${where} must not be empty`);
const seen = new Set();
return commands.map((prefix, i) => {
const at = `${where}[${i}]`;
if (!Array.isArray(prefix) || prefix.length === 0) refuse(`${at} must be a non-empty argv prefix`);
if (prefix.length > ARGV_MAX) refuse(`${at} has more than ${ARGV_MAX} elements`);
for (const element of prefix) {
if (typeof element !== "string" || element === "" || element.includes("\0")) refuse(`${at} elements must be non-empty strings`);
if (/[*?[]/.test(element)) refuse(`${at} must not hold *, ? or [; the match is literal`);
}
if (prefix[0].includes("/") && !prefix[0].startsWith("/")) refuse(`${at}[0] must be a bare name or an absolute path`);
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0]}, a shell, runner or interpreter, which would grant any command`);
const key = JSON.stringify(prefix);
if (seen.has(key)) refuse(`${at} repeats a prefix`);
seen.add(key);
return [...prefix];
});
}
// Old path of packages/organization/src/hosts.mjs (rename design r2, stage B).
export * from "../../organization/src/hosts.mjs";
+2 -16
View File
@@ -1,16 +1,2 @@
// @mosaic/business: role definitions, the business and project files, the
// variable registry and the resolver (slice 1 row S1). Everything here reads
// files and refuses on a problem; nothing writes.
export { BusinessError } from "./errors.mjs";
export {
ACTIONS, GATED_ONLY, HIRE_ALLOWED, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
} from "./vocabulary.mjs";
export { validateRoleDocument, loadRoleFile, loadRole, unhireableAction } from "./role.mjs";
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
export { loadSeatSets } from "./library-seats.mjs";
// Old path of packages/organization/src/index.mjs (rename design r2, stage B).
export * from "../../organization/src/index.mjs";
+2 -477
View File
@@ -1,477 +1,2 @@
// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition,
// vars and launch membership. It is human-only: the broker's instances and instances.set verbs
// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918).
// holder, credentials and tracker stay hand edits. Row 194 (#1692) adds delete, the human's veto
// lists and the chief's hire and fire. Every write goes through the organization writer, which
// logs the actor and the old and new values.
import { existsSync, readdirSync } from "node:fs";
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { validateBusinessDocument, businessFilePath, checkBot } from "./business.mjs";
import { DATE_KEY } from "./credentials.mjs";
import { checkRoutes, creativeHumanOnly } from "./launch-settings.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { resolveInstance } from "./resolve.mjs";
import { loadRole } from "./role.mjs";
import { refuse } from "./errors.mjs";
import { isPlainObject, canonicalJson } from "./util.mjs";
import { CREATIVE_DEFINITIONS, CREATIVE_SERVICE, END_PROTECTED, HOST_SERVICE, ID_PATTERN, checkGrantScope, grantScopeOf } from "./vocabulary.mjs";
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch", "tracker"];
const VETO_KEYS = ["revision", "op", "name"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const SCOPE_KEYS = ["revision", "scope"];
const id = value => typeof value === "string" && ID_PATTERN.test(value);
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
// Every limits.* key merges by intersection, so it can only narrow.
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative
// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a
// change. The assistant is refused for every actor, the human included. The listing's
// endProtected is this same check for its reader, so the Agents page offers no Delete it would refuse.
function protectedRefusal(document, instance, actor) {
const { definition } = document.roles[instance];
if (!END_PROTECTED.includes(definition)) return null;
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
if (actor === document.human) return null;
return { code: "organization-creative-human-only", message: `${instance} is a ${definition} instance, which only the human deletes` };
}
// Row 194 (#1692, V5): why an instance can't be deleted by actor, checked in the design's order, or
// null. The last-listed check runs when the proposed file is validated (deletedDocument).
export function deleteRefusal(document, rolesDir, instance, actor) {
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
const entry = document.roles[instance];
const refused = protectedRefusal(document, instance, actor);
if (refused) return refused;
const references = [];
if (document.launch?.by === instance) references.push("launch.by");
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
if (references.length > 0) {
return { code: "organization-instance-referenced", message: `${instance} is ${references.join(" and ")}; change that first` };
}
if (document.launch?.keep?.includes(instance)) {
return { code: "organization-instance-kept", message: `${instance} is kept; run veto unkeep first` };
}
// Row 194 (brief item 8): delete revokes the agent's store credentials before the write, so an
// instance whose every reference is `store` can go, its tracker bot included (sage-1610 b). A
// file or env reference stays a hand edit.
const refs = Object.values(entry.credentials ?? {});
if (refs.some(ref => ref?.store !== true)) {
return { code: "organization-credentials-out-of-scope", message: `${instance} holds a file or env credential; deleting it is a hand edit` };
}
return null;
}
// The document with `instance` deleted (V5 step 2). It removes roles.<instance> and the references
// that exist only for it, launch.instances and launch.limits.tokens.instances, and adds the name to
// retired. Every other reference (launch.by, the arbiters, launch.keep) refuses in deleteRefusal.
// A business whose only listed instance is this one refuses with a BusinessError.
export function deletedDocument(document, instance) {
const next = structuredClone(document);
delete next.roles[instance];
if (next.launch) {
if (next.launch.instances.length === 1 && next.launch.instances[0] === instance) {
refuse(`${instance} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
next.launch.instances = next.launch.instances.filter(name => name !== instance);
const tokens = next.launch.limits?.tokens?.instances;
if (tokens && Object.hasOwn(tokens, instance)) delete tokens[instance];
}
next.retired = [...(next.retired ?? []), instance];
return next;
}
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
// which stay out of this row (sage-0918); the refusal names that, so a later row lifts it alone.
// Row 213 (C2-08, M10): a creative definition is END_PROTECTED, yet this writer is how the human
// adds and changes one, so it isn't closed here. The caller runs creativeHumanOnly first.
const closed = name => END_PROTECTED.includes(name) && !CREATIVE_DEFINITIONS.includes(name);
// Row 285 (mos-1586): derive creative from the reviewed authority data, not another role-name list.
const creativeDefinition = definition => definition.authority.withinRole.includes("library.write");
const credentialServices = definition => definition.credentials.map(c => c.service).sort();
const sameCredentialServices = (before, after) => credentialServices(before).join(",") === credentialServices(after).join(",");
// Row 194 (brief item 7): the human's add passes credentials true and lifts it. The tester's add,
// change and hire keep it.
function definitionFor(rolesDir, name, { credentials = false } = {}) {
if (closed(name)) throw new OrganizationError("organization-definition-refused");
const definition = loadRole(rolesDir, name);
if (definition.roleVersion !== 2) throw new OrganizationError("invalid-request", `role definition ${name} is version 1`);
if (!credentials && definition.credentials.length > 0) {
throw new OrganizationError("organization-credentials-out-of-scope",
`${name} declares ${definition.credentials.map(c => c.service).join(" and ")} credentials; binding them is a hand edit`);
}
return definition;
}
// Row 194 (brief item 7, sage-1605, sage-1610 a): the human's add of a credentialed definition.
// Each declared service gets a store reference dated the create day (UTC), and no file: the agent
// needs a bus restart, then row 199's issue. A vikunja definition needs args.tracker, an existing
// Vikunja bot the human names. The add makes no Vikunja account. Any other definition refuses it.
function createdCredentials(old, instance, definition, args) {
// webby-0094 N2: a host grant is no token reference (business.mjs checkRoles drops it too).
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE);
const out = {};
if (services.includes("vikunja")) {
if (!Object.hasOwn(args, "tracker")) {
throw new OrganizationError("organization-tracker-required", `${definition.name} uses vikunja; add needs tracker {bot, botId}, an existing Vikunja bot`);
}
let tracker;
try {
tracker = checkBot(args.tracker, "tracker");
} catch (e) {
if (e.name === "BusinessError") throw new OrganizationError("invalid-request", e.message);
throw e;
}
// business.mjs checks the same after the write: no two instances and not the sync bot.
const taken = Object.entries(old.roles).filter(([name, entry]) => name !== instance && entry.tracker).map(([name, entry]) => [name, entry.tracker]);
if (old.tracker?.sync) taken.push(["tracker.sync", old.tracker.sync]);
for (const [name, other] of taken) {
if (other.bot === tracker.bot || other.botId === tracker.botId) {
throw new OrganizationError("organization-tracker-bot-taken", `${name} already uses bot ${other.bot} (botId ${other.botId})`);
}
}
out.tracker = tracker;
} else if (Object.hasOwn(args, "tracker")) {
throw new OrganizationError("invalid-request", `${definition.name} uses no vikunja credential, so it takes no tracker`);
}
if (services.length > 0) {
const today = new Date().toISOString().slice(0, 10);
out.credentials = Object.fromEntries(services.map(service => [service, { store: true, [DATE_KEY[service]]: today }]));
}
return out;
}
// levels(name): the thinking levels harness module `name` applies, or null (launch-settings.mjs
// checkRoutes, row 65). Row 194 (brief item 8, row 199 D13): revoke(business, instance, how,
// services) revokes the instance's credentials for a fire or a delete and throws
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
// The definitions in rolesDir and whether add and set accept each one.
function definitions() {
const out = {};
for (const file of readdirSync(rolesDir).filter(f => f.endsWith(".json")).sort()) {
const name = file.slice(0, -5);
let definition;
try {
definition = loadRole(rolesDir, name);
} catch (e) {
if (e.name === "BusinessError") continue;
throw e;
}
if (definition.roleVersion !== 2) continue;
const services = definition.credentials.map(c => c.service);
// mos-1586: only creative gitea-store adds join the base no-credential listing.
const creativeAdd = creativeDefinition(definition) && services.every(service => service === CREATIVE_SERVICE);
out[name] = { services, accepted: !closed(name) && (services.length === 0 || creativeAdd) };
}
return out;
}
// Row 256: actor is the reader, so endProtected is the delete check for that reader. A read with
// no actor (the bus's reloads) answers as for any non-human.
function read(business, actor) {
const { document, revision } = writer.read(business);
validateBusinessDocument(document, businessFilePath(business, dir), { rolesDir });
const launch = document.launch ?? null;
const instances = Object.fromEntries(Object.entries(document.roles).map(([name, entry]) => [name, {
definition: entry.definition,
vars: entry.vars ?? {},
launched: launch ? launch.instances.includes(name) : false,
launcher: launch ? launch.by === name : false,
protected: closed(entry.definition),
kept: (launch?.keep ?? []).includes(name),
endProtected: protectedRefusal(document, name, actor) !== null,
}]));
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return {
business, revision, launch: launch !== null, instances, definitions: definitions(),
noHire: [...(launch?.noHire ?? [])], retired: [...(document.retired ?? [])],
};
}
// args: { revision, instance, op: "add" | "change" | "delete", definition?, vars?, launch?: boolean }.
// add needs definition; change needs at least one of definition, vars and launch. vars replaces
// the instance's vars whole. Row 194 (D3): the writer's restart is false. The bus reloads the
// business after the write and sets restart from that reload (runtime.mjs).
function set(business, args, actor, catalogue = null) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) {
throw new OrganizationError("invalid-request");
}
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
const { instance, op } = args;
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
// The broker refuses a business whose roles name human, tester or user (broker.mjs, the load
// check), so an instance under any of them would stop the bus at its next start (row 165 r2,
// Dewey F1; FED R3, dewey-0777).
if (instance === "human" || instance === "tester" || instance === "user") {
throw new OrganizationError("invalid-request", `${instance} names the bus's own sessions, never a role instance`);
}
if (Object.hasOwn(args, "definition") && !id(args.definition)) throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && !isPlainObject(args.vars)) throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (op === "delete") {
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
const services = Object.keys(old.roles[instance]?.credentials ?? {});
const beforeWrite = revoke ? () => revoke(business, instance, "delete", services) : null;
const result = commit(business, old, planDelete(old, instance, actor), { revision: args.revision, actor, beforeWrite, catalogue });
// sage-1610 (b): the bot user and any token it holds live in Vikunja, which this write
// can't reach (row 199 A5).
const bot = old.roles[instance]?.tracker?.bot;
return bot ? { ...result, tracker: { bot, removeByHand: `remove the Vikunja bot user ${bot} and any token it holds by hand in Vikunja` } } : result;
}
const next = structuredClone(old);
let creativeChange = null;
const exists = Object.hasOwn(next.roles, instance);
if (op === "add") {
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
if (exists) throw new OrganizationError("organization-instance-exists");
creativeHumanOnly(old, actor, instance, args.definition);
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
// Only the human's add binds credentials. The tester keeps the refusal (row 175 W2): the bus
// names the tester's actor "tester", which no human or role may be called.
const definition = definitionFor(rolesDir, args.definition, { credentials: actor !== "tester" });
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
Object.assign(next.roles[instance], createdCredentials(old, instance, definition, args));
} else {
if (Object.hasOwn(args, "tracker")) throw new OrganizationError("invalid-request", "tracker is set only by add");
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
if (!exists) throw new OrganizationError("organization-unknown-instance");
const entry = next.roles[instance];
// An assistant's settings change only through limits.set's assistants key (row 155), so
// nothing here touches a protected instance, its vars and launch membership included. A
// creative instance is the exception: the human changes it here, and only the human (M10).
creativeHumanOnly(old, actor, instance, entry.definition);
if (closed(entry.definition)) throw new OrganizationError("organization-definition-refused");
if (Object.hasOwn(args, "definition") && args.definition !== entry.definition) {
creativeHumanOnly(old, actor, instance, args.definition);
const before = loadRole(rolesDir, entry.definition);
// mos-1585: a declaration alone is no binding (the chief without a library holds none).
const holdsCredentials = Object.keys(entry.credentials ?? {}).length > 0;
const creative = holdsCredentials && creativeDefinition(before);
const after = definitionFor(rolesDir, args.definition, { credentials: creative });
if (holdsCredentials && !creative) {
throw new OrganizationError("organization-credentials-out-of-scope",
`${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`);
}
if (creative) creativeChange = { before, after };
entry.definition = args.definition;
}
if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars);
}
if (Object.hasOwn(args, "launch")) {
if (!next.launch) throw new OrganizationError("organization-launch-not-configured");
const listed = next.launch.instances;
if (!args.launch) next.launch.instances = listed.filter(name => name !== instance);
else if (!listed.includes(instance)) listed.push(instance);
}
if (creativeChange) {
// mos-1586: validate the complete proposal first, preserving base BusinessError refusals.
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
if (!creativeDefinition(creativeChange.after) || !sameCredentialServices(creativeChange.before, creativeChange.after)) {
throw new OrganizationError("organization-credentials-out-of-scope",
`${instance} may retain credentials only between creative definitions with exactly the same services`);
}
}
return commit(business, old, next, { revision: args.revision, actor, catalogue });
}
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
function planDelete(document, instance, actor) {
const refusal = deleteRefusal(document, rolesDir, instance, actor);
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
return deletedDocument(document, instance);
}
function checkDelete(business, instance, actor) {
const { document, revision } = writer.read(business);
const next = planDelete(document, instance, actor);
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
return { revision };
}
// Row 194 (V6): args { revision, op: keep | unkeep | nohire | hire, name }. keep needs a declared
// instance that isn't END_PROTECTED. nohire needs only the id pattern. Human-only at the broker.
function veto(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !VETO_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision) || !VETO_OPS.includes(args.op) || !id(args.name)) throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
const { op, name } = args;
const key = op === "keep" || op === "unkeep" ? "keep" : "noHire";
const list = next.launch[key] ?? [];
if (op === "keep") {
if (!Object.hasOwn(next.roles, name)) throw new OrganizationError("organization-unknown-instance");
if (END_PROTECTED.includes(next.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
}
const add = op === "keep" || op === "nohire";
if (add === list.includes(name)) throw new OrganizationError("organization-no-change");
next.launch[key] = add ? [...list, name] : list.filter(entry => entry !== name);
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 298 (#1804, Q228): the chief's grant scope as the file stands. scope is the one the broker
// applies, so a bad value reads as {} with fallback true.
function grantScopeRead(business) {
const { document, revision } = writer.read(business);
if (!document.launch) throw new OrganizationError("organization-launch-not-configured");
return { business, revision, ...grantScopeOf(document.launch.grantScope) };
}
// Row 298: args { revision, scope }. Human-only at the broker (grants.scope.set), and the only
// writer of launch.grantScope: SET_KEYS and the organization.set keys don't name it. The scope is
// checked strictly here; {} removes the key. old is what the broker applied before the write,
// the fallback included.
function grantScope(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SCOPE_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision)) throw new OrganizationError("invalid-request");
const scope = checkGrantScope(args.scope);
if (scope === null) throw new OrganizationError("invalid-request", "scope is not a grant scope");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
if (Object.keys(scope).length === 0) delete next.launch.grantScope;
else next.launch.grantScope = scope;
const before = grantScopeOf(old.launch.grantScope).scope;
return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before };
}
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
// (declared, not listed) is listed again only with its own definition and no vars. The writer
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
function prepareHire(business, args) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name, definition } = args;
// FED R3 r4 (sage-2557): and user, the granted user's principal, which the broker's load check refuses.
if (!id(name) || !id(definition) || name === "human" || name === "tester" || name === "user") throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
}
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
// Row 213 (M10): definitionFor lets a creative definition through for the human's add and
// change. A hire is the chief's, so it refuses one here.
if (CREATIVE_DEFINITIONS.includes(definition)) {
throw new OrganizationError("definition-not-hireable", `${definition} is a creative definition, which only the human adds`);
}
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
const next = structuredClone(old);
if (Object.hasOwn(old.roles, name)) {
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
throw new OrganizationError("organization-instance-exists");
}
} else {
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
}
// mos-1585: duplicates refuse before definition eligibility, with no file or credential effect.
definitionFor(rolesDir, definition);
next.launch.instances.push(name);
return { old, next, revision };
}
// Trusted read-only lifecycle preparation; the writer uses the same checks again.
function checkHire(business, args) {
return { revision: prepareHire(business, args).revision };
}
function hire(business, args, { actor, context }) {
const { old, next, revision } = prepareHire(business, args);
return commit(business, old, next, { revision, actor, context });
}
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
function fire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
const { name } = args;
fireChecks(old, name);
// Item 8: fire revokes each service of the entry and keeps the store directory (sage-1593).
const services = Object.keys(old.roles[name].credentials ?? {});
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
// its tokens revoked, with no write. A revoke is safe to repeat.
if (!old.launch.instances.includes(name)) {
const revoked = revoke ? revoke(business, name, "fire", services) : null;
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
let revoked = null;
const beforeWrite = revoke ? () => { revoked = revoke(business, name, "fire", services); } : null;
const result = commit(business, old, next, { revision, actor, context, beforeWrite });
return { ...result, delisted: true, ...(revoked ? { credentials: revoked } : {}) };
}
// Row 194 b6 (webby-0094 B1): the broker runs fire's checks against the file before step A stops
// anything, so a keep its own copy hasn't loaded (a refused reload) refuses there and not at
// step B, which would leave the instance firing. No write.
function checkFire(business, name) {
const { document, revision } = writer.read(business);
fireChecks(document, name);
return { revision };
}
function fireChecks(old, name) {
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
if (!id(name) || !Object.hasOwn(old.roles, name)) throw new OrganizationError("organization-unknown-instance");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
if (END_PROTECTED.includes(old.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
if (old.launch.by === name) throw new OrganizationError("organization-instance-referenced", `${name} is launch.by`);
if (old.launch.instances.includes(name) && old.launch.instances.length === 1) {
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
}
// The checks every write runs, then the write, which runs beforeWrite (a fire's or a delete's
// revoke) under its lock (row 239). A check that refuses revokes nothing, and a revoke that throws
// writes nothing. beforeWrite must not write this business: the writer holds its lock.
function commit(business, old, next, { revision, actor, context, beforeWrite = null, catalogue = null }) {
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
// The checks mosaic business validate runs, before the writer publishes anything, then the
// route checks every write of the route vars runs (row 65). A file that no longer validates
// has every route checked.
const proposed = validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
let before = null;
try {
before = validateBusinessDocument(old, businessFilePath(business, dir), { rolesDir });
} catch (e) {
if (e.name !== "BusinessError") throw e;
}
checkRoutes({ proposed, old: before, levels, catalogue });
for (const [project, entry] of Object.entries(proposed.projects)) {
if (entry.kind === 'library') continue;
if (!existsSync(projectFilePath(entry.root))) continue;
const loaded = loadProject(entry.root);
if (loaded.id !== project) throw new OrganizationError("organization-project-mismatch");
for (const name of Object.keys(proposed.roles)) {
try {
resolveInstance({ system, business: proposed, project: loaded, instance: name });
} catch (e) {
if (e.name === "BusinessError") throw new OrganizationError("organization-unresolvable-instance", e.message);
throw e;
}
}
}
// Row 194 N1 (sage-1634): a delete writes on the revision its request read when it was raised,
// so a write since then makes it stale. This early check refuses it without taking the lock. The
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
// a stale fire or delete from revoking.
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
return { writer, read, set, checkDelete, checkFire, checkHire, veto, hire, fire, grantScope, grantScopeRead };
}
// Old path of packages/organization/src/instance-settings.mjs (rename design r2, stage B).
export * from "../../organization/src/instance-settings.mjs";
+2 -407
View File
@@ -1,407 +1,2 @@
// Shared human settings projection/update (REQ-LIMIT-1), used by the trusted broker.
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
import { resolveInstance } from "./resolve.mjs";
import { usageCounts } from "./launch.mjs";
import { canonicalJson, isPlainObject, requireObject, rejectUnknownKeys } from "./util.mjs";
import { FALLBACK_VARS } from "./vars.mjs";
import { THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
// Row 213 (C2-08, Case 2 design 4, M10): a creative instance, its persona included, is the
// human's. A write that touches one is accepted only when its actor is the business file's
// human; the broker sets the actor to the human's id, "tester" or <definition>.<instance>.
export function creativeHumanOnly(document, actor, instance, definition) {
if (CREATIVE_DEFINITIONS.includes(definition) && actor !== document.human) {
throw new OrganizationError("organization-creative-human-only", `${instance} is a ${definition} instance, which only the human changes`);
}
}
// Row 194 (D2.4 B7, webby-0094 N3): only the human changes a kept instance's model, through the
// instance's own key or by re-pointing the launch.models entry that key names (the chief and the
// tester are refused, as row 213 refuses them a creative instance's). The check reads the file, so a
// keep the broker's copy hasn't loaded holds too.
function keptHumanOnly(document, actor, instance) {
if (actor !== document.human && (document.launch?.keep ?? []).includes(instance)) {
throw new OrganizationError("instance-kept", `${instance} is kept; only the human changes its model`);
}
}
// Row 155 (#1649, Jason's ruling Q169): the fields of an assistant instance's entry the human and
// the chief of staff may change. holder and credentials stay hand edits.
const ASSISTANT_FIELDS = ["vars", "tracker", "definition"];
// Every key a limits update takes. The broker's and the harness tool's
// ORGANIZATION_KEYS must match it (packages/harness/tests/tools.test.mjs).
export const SETTINGS_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset", "assistants"]);
const own = (value, key) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key);
const plain = value => value !== null && typeof value === "object" && !Array.isArray(value);
// Row 125 (#1619, REQ-LIMIT-3): token limits sit only at their schema depth, tokens.instances.X,
// tokens.providers.X, tokens.keys.X, tokens.models.P.M and (row 126) tokens.daily.X. Nothing else in launch.limits is one,
// so a concurrency provider or model named `limit`, or a token limit's subject named `limit`, is
// never mistaken for a leaf. The bus core's limitLeaves walks the same depths.
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 };
function tokenLeaves(limits) {
const out = [];
const walk = (value, depth, path) => {
if (!plain(value)) return;
for (const [key, entry] of Object.entries(value)) {
if (depth > 1) walk(entry, depth - 1, [...path, key]);
else out.push({ path: [...path, key], entry });
}
};
const tokens = own(limits, "tokens") ? limits.tokens : undefined;
if (plain(tokens)) {
for (const [kind, value] of Object.entries(tokens)) if (Object.hasOwn(DEPTH, kind)) walk(value, DEPTH[kind], ["tokens", kind]);
}
return out;
}
const at = (value, path) => path.reduce((v, part) => (own(v, part) ? v[part] : undefined), value);
// The writer, not the caller, sets a token limit's `since`. A new limit gets now; a changed or
// unchanged one keeps its stored time; a caller-supplied time that differs from the stored one is
// refused, so only a reset moves it. An entry that isn't an object is left for the schema to refuse.
// Row 333 R4 (#1831): a new daily key quota carries its agent's count (carried, below).
function stamp(next, old, now, lineage) {
for (const { path, entry } of tokenLeaves(next)) {
if (!plain(entry)) continue;
const before = at(old, path);
const kept = plain(before) ? before.since : undefined;
if (own(entry, "since") && entry.since !== kept) throw new OrganizationError("invalid-request");
entry.since = kept ?? carried(path, entry, old, now, lineage) ?? now;
}
}
// Row 333 R4 (#1831): a new tokens.keys leaf in the daily quota shape is a quota on one agent's model
// key. It takes the earliest stored `since` of the old document's daily key quotas whose ids are in
// that key's lineage, so moving a quota from A to B after a rotation or re-issue keeps the day's
// count, whether or not the same write removes A. With none it is new and gets now (undefined
// here). lineage(id) answers the ids of the agent holding id, read from the bus's own credential
// records, or [] when no agent holds it. A lookup that throws or answers malformed refuses the write.
// A quota removed in one write and added back in a later one finds no old leaf, so it gets now.
const KEY_ID = /^[a-f0-9]{12}$/;
function carried(path, entry, old, now, lineage) {
const [, kind, id] = path;
if (kind !== "keys" || !isDailyModelQuota(id, { ...entry, since: now })) return undefined;
let ids;
try { ids = lineage(id); } catch { throw new OrganizationError("proxy-key-refused"); }
if (!Array.isArray(ids) || ids.some(x => typeof x !== "string" || !KEY_ID.test(x)) || (ids.length && !ids.includes(id))) {
throw new OrganizationError("proxy-key-refused");
}
const keys = at(old, ["tokens", "keys"]);
const times = Object.entries(plain(keys) ? keys : {})
.filter(([key, value]) => ids.includes(key) && isDailyModelQuota(key, value)).map(([, value]) => value.since);
return times.sort((a, b) => Date.parse(a) - Date.parse(b))[0];
}
// A reset names token limit paths, e.g. ["tokens", "instances", "coder-1"]; each gets since = now.
// Any other path, a concurrency limit's included, is refused.
function reset(limits, paths, now) {
if (!Array.isArray(paths) || !paths.length) throw new OrganizationError("invalid-request");
const leaves = tokenLeaves(limits);
for (const path of paths) {
if (!Array.isArray(path) || !path.length || path.some(part => typeof part !== "string")) {
throw new OrganizationError("invalid-request");
}
const leaf = leaves.find(l => l.path.length === path.length && l.path.every((part, i) => part === path[i]));
if (!leaf || !plain(leaf.entry)) throw new OrganizationError("invalid-request");
leaf.entry.since = now;
}
}
// Row 65 (REQ-PLUG-2): the checks every write of an instance's route vars runs: the routes table's,
// instances.set's and limits.set's. Each instance's model, and its fallback model when it states a
// fallback, must be a launch.models key. A route the write changes must name a harness that is
// switched on in plugins.harness (harness-off) and whose module applies its thinking level
// (thinking-unsupported). levels(name) answers the levels a harness module applies, or null when
// the module didn't load, which refuses harness-refused. An unchanged route isn't checked, so a
// model change still saves while plugins.harness is unset; that instance's launch refuses instead.
// A route var the instance doesn't set is left to the launch, which refuses no-route.
const ROUTES = Object.freeze([["harness", "model", "thinking"], FALLBACK_VARS]);
export function checkRoutes({ proposed, old = null, levels, catalogue = null }) {
const on = Array.isArray(proposed.plugins?.harness) ? proposed.plugins.harness : [];
for (const [instance, entry] of Object.entries(proposed.roles)) {
const vars = entry.vars;
const before = old && Object.hasOwn(old.roles, instance) ? old.roles[instance].vars : {};
for (const [harness, model, thinking] of ROUTES) {
// A missing model refuses no-route at launch, not here.
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
throw new OrganizationError("unknown-model", `${instance} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
}
const currentEntry = proposed.launch?.models?.[vars[model]];
const previousEntry = old?.launch?.models?.[before[model]];
const changed = [harness, model, thinking].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
if (changed && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
if (changed && currentEntry?.module) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const offered = catalogue.models?.find(m => m.key === vars[model]);
if (!offered || !offered.available || !proposed.plugins?.['model-provider']?.includes(currentEntry.module) || canonicalJson(currentEntry) !== canonicalJson({ provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream })) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
}
if (changed && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const mapping = catalogue.resolveLegacy ? catalogue.resolveLegacy(currentEntry) : catalogue.legacy?.[vars[model]];
const offered = mapping && catalogue.models?.find(m => m.module === mapping.module && m.upstream === mapping.upstream && m.model === currentEntry.model);
if (!offered?.available) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
}
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
const name = vars[harness];
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} ${harness} ${name} isn't switched on in plugins.harness`);
if (vars[thinking] === undefined) continue;
const applies = levels(name);
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `harness module ${name} didn't load, so its thinking levels are unknown`);
if (!applies.includes(vars[thinking])) {
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
}
}
}
}
const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
// levels(name): the thinking levels harness module `name` applies, or null (checkRoutes).
// Row 333 R4 (#1831): keyLineage(business, keyId) answers the key ids in the lineage of the agent
// that holds keyId (carried, above). The bus runtime passes one read from its credential records.
// Without it, a write that adds a daily key quota refuses proxy-key-refused; no other write asks.
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null,
keyLineage = () => { throw new OrganizationError("proxy-key-refused"); } }) {
const writer = organizationWriter({ dir, rolesDir });
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
function current(id) {
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir });
}
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
// route is checked.
function before(id, document) {
try {
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
} catch (e) {
if (e.name === "BusinessError") return null;
throw e;
}
}
function view(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
const vars = resolveInstance({ business, system, instance }).vars;
const key = vars.model;
// No model var reads as unset; the launch refuses it no-route.
if (key === undefined) return [instance, { harness: vars.harness ?? null, provider: null, model: null, modelKey: null }];
const entry = Object.hasOwn(business.launch.models, key) ? business.launch.models[key] : null;
if (!entry) throw new OrganizationError("unknown-model");
return [instance, { harness: vars.harness ?? null, ...entry, modelKey: key }];
}));
// Each assistant instance's entry fields as the file has them, so a change replaces what it shows.
const assistants = Object.fromEntries(Object.entries(document.roles)
.filter(([, entry]) => entry.definition === THREAD_DEFINITION)
.map(([instance, entry]) => [instance, {
definition: entry.definition, vars: entry.vars ?? {}, tracker: entry.tracker ?? null,
}]));
// model is the external id; modelKey is the table selection.
return {
business: id, revision, models: business.launch.models,
limits: business.launch.limits, instances, assistants, usage: usageCounts(sessions(), id),
};
}
function set(id, args, actor, catalogue = null) {
requireObject(args, "limits update");
rejectUnknownKeys(args, SETTINGS_KEYS, "limits update");
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
if (!SETTINGS_KEYS.some(key => key !== "revision" && Object.hasOwn(args, key))) {
throw new OrganizationError("invalid-request");
}
const old = writer.read(id);
const next = structuredClone(old.document);
if (!next.launch) throw new OrganizationError("launch-not-configured");
const at = now();
if (Object.hasOwn(args, "limits")) {
next.launch.limits = structuredClone(args.limits);
stamp(next.launch.limits, old.document.launch.limits, at, key => keyLineage(id, key));
}
if (Object.hasOwn(args, "reset")) reset(next.launch.limits, args.reset, at);
if (Object.hasOwn(args, "models")) {
if (actor !== old.document.human && plain(args.models)) {
for (const [key, entry] of Object.entries(args.models)) {
if (entry?.module && canonicalJson(entry) !== canonicalJson(old.document.launch.models[key] ?? null)) throw new OrganizationError('organization-model-provider-human-only');
// Resolve changed legacy entries with the trusted module resolver; a table key
// alone does not authorize introducing a different catalogue identity.
const previous = old.document.launch.models[key];
if (canonicalJson(entry) !== canonicalJson(previous ?? null) && plain(entry) && !entry.module && old.document.plugins?.['model-provider']?.length) {
if (catalogue?.status !== 'ready' || typeof catalogue.resolveLegacy !== 'function' || !Array.isArray(catalogue.models)) throw new OrganizationError('model-provider-unavailable');
const mapping = catalogue.resolveLegacy(entry);
const prior = previous?.module
? { module: previous.module, upstream: previous.upstream, model: previous.model }
: plain(previous) ? catalogue.resolveLegacy(previous) : null;
if (mapping && canonicalJson(mapping) !== canonicalJson(prior) && catalogue.models.some(model => model.module === mapping.module && model.upstream === mapping.upstream && model.model === entry.model)) {
throw new OrganizationError('organization-model-provider-human-only');
}
}
}
}
// M10: replacing launch.models can change the model behind a key a creative instance names
// without touching the instance, so a change to or removal of such a key is the human's.
for (const [instance, entry] of Object.entries(old.document.roles)) {
const vars = entry.vars ?? {};
for (const key of [vars.model, vars["fallback.model"]]) {
if (typeof key !== "string") continue;
const was = own(old.document.launch.models, key) ? canonicalJson(old.document.launch.models[key]) : null;
const is = own(args.models, key) ? canonicalJson(args.models[key]) : null;
if (was !== is) {
creativeHumanOnly(old.document, actor, instance, entry.definition);
keptHumanOnly(old.document, actor, instance);
}
}
}
next.launch.models = args.models;
}
if (Object.hasOwn(args, "instances")) {
requireObject(args.instances, "instance model selections");
for (const [instance, key] of Object.entries(args.instances)) {
if (!Object.hasOwn(next.roles, instance) || typeof key !== "string") {
throw new OrganizationError("invalid-request");
}
// M10: a creative instance's model changes through instances.set or routes.set, whoever asks.
if (CREATIVE_DEFINITIONS.includes(next.roles[instance].definition)) {
throw new OrganizationError("organization-creative-instance", `${instance}'s model changes through instances.set or routes.set`);
}
keptHumanOnly(old.document, actor, instance);
next.roles[instance].vars = { ...next.roles[instance].vars, model: key };
}
}
// Each field given replaces that field; tracker null removes it. Only an assistant instance is a
// target, and its definition stays one the broker treats as the assistant, so the change can't
// strip END_PROTECTED's protection.
if (Object.hasOwn(args, "assistants")) {
requireObject(args.assistants, "assistant settings");
for (const [instance, entry] of Object.entries(args.assistants)) {
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("invalid-request");
if (next.roles[instance].definition !== THREAD_DEFINITION) throw new OrganizationError("organization-not-assistant");
requireObject(entry, `assistant settings ${instance}`);
rejectUnknownKeys(entry, ASSISTANT_FIELDS, `assistant settings ${instance}`);
if (Object.keys(entry).length === 0) throw new OrganizationError("invalid-request");
if (Object.hasOwn(entry, "definition")) {
if (entry.definition !== THREAD_DEFINITION) throw new OrganizationError("organization-definition-refused");
next.roles[instance].definition = entry.definition;
}
if (Object.hasOwn(entry, "vars")) next.roles[instance].vars = requireObject(entry.vars, `assistant settings ${instance}.vars`);
if (entry.tracker === null) delete next.roles[instance].tracker;
else if (Object.hasOwn(entry, "tracker")) next.roles[instance].tracker = entry.tracker;
}
}
// No key may change another instance's definition, so none gives an instance an END_PROTECTED one
// after a decision about it was resolved (dewey-0546).
for (const [instance, entry] of Object.entries(old.document.roles)) {
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
}
// Check every instance's route before publishing the complete document.
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...view(id), transaction: receipt.transaction };
}
// Row 65 (mos-0840): the routing table. One row per instance with its harness, model, thinking
// and fallback, and what each cell offers: the switched-on harnesses, the launch.models keys and
// the levels each harness applies (null for a module that didn't load). A route var the instance
// doesn't set reads null; its launch refuses no-route.
function routes(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
const named = new Set(harnesses);
const instances = Object.fromEntries(Object.entries(business.roles).map(([instance, { vars }]) => {
for (const key of ["harness", "fallback.harness"]) if (Object.hasOwn(vars, key)) named.add(vars[key]);
return [instance, {
harness: vars.harness ?? null, model: vars.model ?? null, thinking: vars.thinking ?? null,
fallback: Object.hasOwn(vars, "fallback.harness")
? { harness: vars["fallback.harness"], model: vars["fallback.model"], thinking: vars["fallback.thinking"] } : null,
}];
}));
return {
business: id, revision, harnesses, models: Object.keys(business.launch.models),
providerModels: Object.fromEntries(Object.entries(business.launch.models).filter(([, entry]) => entry.module).map(([key, entry]) => [key, { module: entry.module, upstream: entry.upstream, model: entry.model }])),
levels: Object.fromEntries([...named].sort().map(name => [name, levels(name)])), instances,
};
}
// args: { revision, routes: { <instance>: { harness, model, thinking, fallback } } }, fallback
// being null or { harness, model, thinking }. Each named instance's route vars are replaced in one
// organization write; a null fallback clears all three. It is human-only and outside
// ORGANIZATION_KEYS (the broker's routes.set). The chief of staff's tool changes the model key,
// and the assistant's whole vars (Q169), through limits.set, which runs the same checkRoutes.
function setRoutes(id, args, actor, catalogue = null) {
requireObject(args, "routes update");
rejectUnknownKeys(args, ["revision", "routes"], "routes update");
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
if (!isPlainObject(args.routes) || Object.keys(args.routes).length === 0) throw new OrganizationError("invalid-request");
const old = writer.read(id);
const next = structuredClone(old.document);
if (!next.launch) throw new OrganizationError("launch-not-configured");
const exact = (value, keys) => isPlainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
for (const [instance, route] of Object.entries(args.routes)) {
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("organization-unknown-instance");
creativeHumanOnly(old.document, actor, instance, next.roles[instance].definition);
// webby-0095 N1, sage-2248: a route is a second write of a kept instance's model.
keptHumanOnly(old.document, actor, instance);
if (!exact(route, [...ROUTE_FIELDS, "fallback"]) || ROUTE_FIELDS.some(key => typeof route[key] !== "string")) {
throw new OrganizationError("invalid-request");
}
const { fallback } = route;
if (fallback !== null && (!exact(fallback, ROUTE_FIELDS) || ROUTE_FIELDS.some(key => typeof fallback[key] !== "string"))) {
throw new OrganizationError("organization-partial-fallback", `${instance}'s fallback sets all of harness, model and thinking or is cleared`);
}
// The client supplies only a key. New entries come from the trusted snapshot.
const priorVars = old.document.roles[instance].vars ?? {};
for (const [choice, prefix] of [[route, ''], [fallback, 'fallback.']]) {
if (!choice) continue;
const unchanged = ROUTE_FIELDS.every(field => priorVars[prefix + field] === choice[field]);
const offered = catalogue?.models?.find(m => m.key === choice.model);
const existing = next.launch.models[choice.model];
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
if (!existing && !offered && catalogue?.reason === 'model-key-collision') throw new OrganizationError('model-key-collision');
if (!existing && !offered && catalogue?.status === 'unavailable') throw new OrganizationError('model-catalogue-unavailable');
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} model isn't a launch.models key`);
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
if (!existing || existing.module) {
if (unchanged) continue;
if (catalogue?.status !== 'ready') throw new OrganizationError('model-catalogue-unavailable');
if (!offered || !offered.available || !Array.isArray(next.plugins?.['model-provider']) || !next.plugins['model-provider'].includes(offered.module)) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(choice.harness)) throw new OrganizationError('model-harness-incompatible');
if (existing && canonicalJson(existing) !== canonicalJson(entry)) throw new OrganizationError('model-key-collision');
if (!existing) {
if (actor !== old.document.human) throw new OrganizationError('organization-model-provider-human-only');
next.launch.models[choice.model] = entry;
}
}
}
const vars = { ...next.roles[instance].vars };
for (const key of FALLBACK_VARS) delete vars[key];
for (const key of ROUTE_FIELDS) {
vars[key] = route[key];
if (fallback !== null) vars[`fallback.${key}`] = fallback[key];
}
next.roles[instance].vars = vars;
}
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...routes(id), transaction: receipt.transaction };
}
return { writer, read: view, set, current, routes, setRoutes };
}
// Old path of packages/organization/src/launch-settings.mjs (rename design r2, stage B).
export * from "../../organization/src/launch-settings.mjs";
+2 -176
View File
@@ -1,176 +1,2 @@
// REQ-LIMIT-1: editable concurrency policy, never a compiled-in capacity ceiling.
import { requireObject, requireId, requireString, rejectUnknownKeys } from "./util.mjs";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
import { BusinessError, refuse } from "./errors.mjs";
const name = (value, at) => requireString(value, at, { max: 200 });
const count = (value, at) => {
if (!Number.isSafeInteger(value) || value < 0) refuse(`${at} must be a whole number of 0 or more`);
return value;
};
const own = (object, key) => Object.hasOwn(object ?? {}, key) ? object[key] : undefined;
export function checkModels(value, where = "launch.models") {
requireObject(value, where);
if (!Object.keys(value).length) refuse(`${where} must name at least one model`);
return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
requireId(key, where);
requireObject(entry, `${where}.${key}`);
const moduleBacked = Object.hasOwn(entry, "module") || Object.hasOwn(entry, "upstream");
rejectUnknownKeys(entry, moduleBacked ? ["provider", "model", "module", "upstream"] : entry.provider === "proxy" ? ["provider", "model", "route"] : ["provider", "model"], `${where}.${key}`);
if (moduleBacked) {
requireId(entry.module, `${where}.${key}.module`);
requireId(entry.upstream, `${where}.${key}.upstream`);
if (typeof entry.model !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(entry.model)) refuse(`${where}.${key}.model is invalid`);
}
if (entry.provider === "proxy" && !moduleBacked) {
if (!Object.hasOwn(entry, "route")) throw Object.assign(new BusinessError(`${where}.${key}.route is required`), { code: "proxy-route-missing" });
if (typeof entry.route !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(entry.route)) throw Object.assign(new BusinessError(`${where}.${key}.route is invalid`), { code: "proxy-route-unknown" });
}
return [key, {
provider: name(entry.provider, `${where}.${key}.provider`),
model: name(entry.model, `${where}.${key}.model`),
...(moduleBacked ? { module: entry.module, upstream: entry.upstream } : {}),
...(entry.provider === "proxy" && !moduleBacked ? { route: entry.route } : {}),
}];
}));
}
// Row 125 (#1619, REQ-LIMIT-3): one token limit, a count of 0 or more and the
// canonical time it was set or last reset. The settings writer stamps `since`.
const SINCE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const tokenLimit = (value, at) => {
requireObject(value, at);
rejectUnknownKeys(value, ["limit", "since"], at);
const since = value.since;
if (typeof since !== "string" || !SINCE.test(since) || Number.isNaN(Date.parse(since)) ||
new Date(since).toISOString() !== since) refuse(`${at}.since must be a canonical timestamp`);
return { limit: count(value.limit, `${at}.limit`), since };
};
// Row 126 (#1620, REQ-LIMIT-2): a daily quota is a token limit with the wall-clock time its day
// starts and the IANA zone that time is read in. A zone must be a name (an offset such as +05:00
// is refused) that Intl accepts.
const RESET = /^([01]\d|2[0-3]):[0-5]\d$/;
const ZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
const dailyQuota = (value, at) => {
requireObject(value, at);
rejectUnknownKeys(value, ["limit", "since", "reset", "timeZone"], at);
const { limit, since } = tokenLimit({ limit: value.limit, since: value.since }, at);
if (typeof value.reset !== "string" || !RESET.test(value.reset)) refuse(`${at}.reset must be a time of day, HH:MM`);
const timeZone = value.timeZone;
let known = typeof timeZone === "string" && ZONE.test(timeZone);
if (known) {
try { new Intl.DateTimeFormat("en-US", { timeZone }); } catch { known = false; }
}
if (!known) refuse(`${at}.timeZone must be an IANA time zone name`);
return { limit, since, reset: value.reset, timeZone };
};
const tokenMap = (value, at, key, each) => {
requireObject(value, at);
return Object.fromEntries(Object.entries(value).map(([k, v]) => [key(k, at), each(v, `${at}.${k}`)]));
};
// launch.limits.tokens: instances, providers, models (per provider), keys and daily, each optional.
export function checkTokenLimits(value, where = "launch.limits.tokens") {
requireObject(value, where);
rejectUnknownKeys(value, ["instances", "providers", "models", "keys", "daily"], where);
const out = {};
if (Object.hasOwn(value, "instances")) out.instances = tokenMap(value.instances, `${where}.instances`, requireId, tokenLimit);
if (Object.hasOwn(value, "providers")) out.providers = tokenMap(value.providers, `${where}.providers`, name, tokenLimit);
if (Object.hasOwn(value, "models")) {
out.models = tokenMap(value.models, `${where}.models`, name, (models, at) => tokenMap(models, at, name, tokenLimit));
}
// P5 adds daily model leaves, including digit-leading IDs; plain keys keep
// their existing account-name schema and cumulative account semantics.
if (Object.hasOwn(value, "keys")) {
requireObject(value.keys, `${where}.keys`);
out.keys = Object.fromEntries(Object.entries(value.keys).map(([key, entry]) => {
const at = `${where}.keys.${key}`;
if (isDailyModelQuota(key, entry)) return [key, dailyQuota(entry, at)];
return [requireId(key, `${where}.keys`), tokenLimit(entry, at)];
}));
}
// One quota per account; the business read checks each key names launch.account.
if (Object.hasOwn(value, "daily")) out.daily = tokenMap(value.daily, `${where}.daily`, requireId, dailyQuota);
return out;
}
// Row 126 (#1620): the account a business's claude-code launches use. `credential` names the
// environment variables of one credential set, joined with "+", so a secret can't pass the check.
// Which sets exist is the launcher's to check (seat imports business, not the other way).
const CREDENTIAL = /^[A-Z][A-Z0-9_]*(\+[A-Z][A-Z0-9_]*)*$/;
export function checkAccount(value, where = "launch.account") {
requireObject(value, where);
rejectUnknownKeys(value, ["name", "credential"], where);
const accountName = requireId(value.name, `${where}.name`);
if (typeof value.credential !== "string" || (value.credential !== "proxy" && !CREDENTIAL.test(value.credential))) {
refuse(`${where}.credential must name environment variables, joined with "+"`);
}
return { name: accountName, credential: value.credential };
}
export function checkLimits(value = {}, where = "launch.limits") {
requireObject(value, where);
rejectUnknownKeys(value, ["total", "providers", "models", "tokens"], where);
const out = {};
if (Object.hasOwn(value, "total")) out.total = count(value.total, `${where}.total`);
if (Object.hasOwn(value, "providers")) {
requireObject(value.providers, `${where}.providers`);
out.providers = Object.fromEntries(Object.entries(value.providers).map(([provider, limit]) => {
return [name(provider, where), count(limit, `${where}.providers.${provider}`)];
}));
}
if (Object.hasOwn(value, "models")) {
requireObject(value.models, `${where}.models`);
out.models = Object.fromEntries(Object.entries(value.models).map(([provider, models]) => {
name(provider, where);
requireObject(models, where);
return [provider, Object.fromEntries(Object.entries(models).map(([model, limit]) => {
return [name(model, where), count(limit, `${where}.models.${provider}.${model}`)];
}))];
}));
}
if (Object.hasOwn(value, "tokens")) out.tokens = checkTokenLimits(value.tokens, `${where}.tokens`);
return out;
}
export function usageCounts(sessions, business) {
const running = sessions.filter(session => session.business === business);
const providerCounts = new Map();
const modelCounts = new Map();
for (const session of running) {
const { provider, model } = session;
if (typeof provider !== "string" || typeof model !== "string") {
refuse("running session has no exact provider/model; reconcile before launching");
}
providerCounts.set(provider, (providerCounts.get(provider) ?? 0) + 1);
if (!modelCounts.has(provider)) modelCounts.set(provider, new Map());
const models = modelCounts.get(provider);
models.set(model, (models.get(model) ?? 0) + 1);
}
const providers = Object.fromEntries(providerCounts);
const models = Object.fromEntries([...modelCounts].map(([provider, counts]) => {
return [provider, Object.fromEntries(counts)];
}));
return { total: running.length, providers, models };
}
export function limitHit(sessions, business, limits, selected) {
const usage = usageCounts(sessions, business);
const { provider, model } = selected;
const checks = [
["total", limits.total, usage.total],
["provider", own(limits.providers, provider), own(usage.providers, provider) ?? 0],
["model", own(own(limits.models, provider), model), own(own(usage.models, provider), model) ?? 0],
];
for (const [scope, limit, running] of checks) {
if (limit !== undefined && running >= limit) {
return {
scope, limit, running,
...(scope !== "total" ? { provider } : {}),
...(scope === "model" ? { model } : {}),
};
}
}
return null;
}
// Old path of packages/organization/src/launch.mjs (rename design r2, stage B).
export * from "../../organization/src/launch.mjs";
+2 -51
View File
@@ -1,51 +1,2 @@
// Case 2 design 5.1 (M7, M8, row 220): which seats work in a library business,
// and which of those hold a creative definition. Read from the business files on
// every call, so an edit takes effect without a restart. Fail closed: a business
// file that can't be read or validated throws. So does anything at the businesses
// path that can't be listed: a file, a dangling symlink, or a directory that can't
// be read. Only a path with nothing at it (lstat ENOENT) holds no library business,
// so both sets are empty. No caller turns a throw into an empty set.
import { lstatSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BusinessError } from "./errors.mjs";
import { configDir, loadBusiness } from "./business.mjs";
import { CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
export function defaultRolesDir(env = process.env) {
return resolve(env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
}
// Returns { librarySeats, creativeSeats }: Sets of seat names (the role keys of
// each business). A seat in two businesses counts when either one puts it in a
// set. creativeSeats is a subset of librarySeats.
export function loadSeatSets({ dir = configDir(), rolesDir = defaultRolesDir() } = {}) {
// lstat first: only nothing at the path is an absent directory. Anything else
// there (a file, a dangling symlink, a directory) must list, or it throws.
const businesses = join(dir, "businesses");
try {
lstatSync(businesses);
} catch (err) {
if (err.code === "ENOENT") return { librarySeats: new Set(), creativeSeats: new Set() };
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
}
let names;
try {
names = readdirSync(businesses).filter((name) => name.endsWith(".json"));
} catch (err) {
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
}
const librarySeats = new Set();
const creativeSeats = new Set();
for (const name of names.sort()) {
const business = loadBusiness(name.slice(0, -".json".length), { dir, rolesDir });
if (!Object.values(business.projects).some((project) => project.kind === "library")) continue;
for (const [seat, role] of Object.entries(business.roles)) {
librarySeats.add(seat);
if (CREATIVE_DEFINITIONS.includes(role.definition)) creativeSeats.add(seat);
}
}
return { librarySeats, creativeSeats };
}
// Old path of packages/organization/src/library-seats.mjs (rename design r2, stage B).
export * from "../../organization/src/library-seats.mjs";
+2 -94
View File
@@ -1,94 +1,2 @@
// Decision 145: pure print-only conversion. System values are supplied by the
// existing read-only config loader; this module never reads credentials or writes.
import { checkModels, checkLimits } from "./launch.mjs";
import { requireObject, rejectUnknownKeys } from "./util.mjs";
import { refuse } from "./errors.mjs";
import { validateBusinessDocument } from "./business.mjs";
export function migrateLaunch(document, mapping, file, { rolesDir, system = null }) {
const out = structuredClone(document);
if (!out.launch?.max) {
validateBusinessDocument(out, file, { rolesDir });
return out;
}
requireObject(mapping, "migration mapping");
rejectUnknownKeys(mapping, ["models", "families", "selections", "instances"], "migration mapping");
const models = checkModels(mapping.models);
requireObject(mapping.families, "migration families");
requireObject(out.launch.max, "old launch.max");
for (const field of ["instances", "selections"]) {
if (Object.hasOwn(mapping, field)) requireObject(mapping[field], `migration ${field}`);
}
for (const key of Object.keys(mapping.instances ?? {})) {
if (!Object.hasOwn(out.roles, key)) refuse(`unknown migration instance ${key}`);
}
const limits = { models: Object.create(null) };
const used = new Set();
for (const [family, count] of Object.entries(out.launch.max)) {
const key = mapping.families[family];
if (typeof key !== "string" || !Object.hasOwn(models, key)) {
refuse(`unknown migration mapping for family ${family}`);
}
const entry = models[key];
const tuple = JSON.stringify([entry.provider, entry.model]);
if (used.has(tuple)) refuse("ambiguous migration: two family limits map to the same provider/model");
used.add(tuple);
limits.models[entry.provider] ??= Object.create(null);
limits.models[entry.provider][entry.model] = count;
}
const checkedLimits = checkLimits(limits);
const previous = new Map();
for (const [instance, role] of Object.entries(out.roles)) {
const inherited = role.vars?.model === undefined;
let old = role.vars?.model;
let provider = null;
if (inherited) {
old = system?.["execution.model"];
provider = system?.["execution.provider"];
if (typeof old !== "string" || !old || typeof provider !== "string" || !provider) {
refuse(`inherited model for ${instance} requires valid system config`);
}
}
previous.set(instance, old);
let selected = mapping.instances?.[instance] ?? mapping.selections?.[old];
if (selected === undefined) {
const matches = Object.entries(models).filter(([, entry]) => {
return entry.model === old && (!inherited || entry.provider === provider);
});
if (matches.length !== 1) refuse(`unknown or ambiguous model mapping for ${instance}`);
selected = matches[0][0];
}
if (typeof selected !== "string" || !Object.hasOwn(models, selected)) {
refuse(`unknown or ambiguous model mapping for ${instance}`);
}
const entry = models[selected];
if (entry.model !== old || (inherited && entry.provider !== provider)) {
refuse(`migration mapping changes ${instance}'s ${inherited ? "inherited provider/model" : "external model"}`);
}
role.vars = { ...role.vars, model: selected };
}
for (const instance of [out.launch.by, ...out.launch.instances]) {
const selected = models[out.roles[instance].vars.model];
const old = previous.get(instance);
const families = Object.keys(out.launch.max).filter(f => old.toLowerCase().includes(f.toLowerCase()));
if (families.length !== 1) refuse(`unknown or ambiguous family mapping for launchable ${instance}`);
const constrained = models[mapping.families[families[0]]];
if (selected.provider !== constrained.provider || selected.model !== constrained.model) {
refuse(`ambiguous migration mapping for launchable ${instance}`);
}
}
for (const family of Object.keys(out.launch.max)) {
const entry = models[mapping.families[family]];
for (const [instance, old] of previous) {
if (!old.toLowerCase().includes(family.toLowerCase())) continue;
const actual = models[out.roles[instance].vars.model];
if (old !== entry.model || actual.provider !== entry.provider) {
refuse(`ambiguous migration: family ${family} spans multiple provider/model pairs`);
}
}
}
const { max, ...launch } = out.launch;
out.launch = { ...launch, models, limits: checkedLimits };
validateBusinessDocument(out, file, { rolesDir });
return out;
}
// Old path of packages/organization/src/migrate-launch.mjs (rename design r2, stage B).
export * from "../../organization/src/migrate-launch.mjs";
+2 -214
View File
@@ -1,214 +1,2 @@
import { constants, openSync, closeSync, fstatSync, lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import { dirname, isAbsolute, join, normalize, parse, resolve } from 'node:path';
import { createHash, randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
export class OrgError extends Error {
constructor(code, message, path) { super(message); this.name = 'OrgError'; this.code = code; if (path) this.path = path; }
}
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
function idCheck(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid organization id'); }
export function orgRoot(dataRoot, id) { idCheck(id); if (!isAbsolute(dataRoot) || normalize(dataRoot) !== dataRoot) fail('org-invalid', 'data root must be a normalized absolute path'); return join(dataRoot, 'org-runtime', id); }
async function safeDir(path, create = false) {
if (!isAbsolute(path) || resolve(path) !== path) fail('org-mismatch', 'directory must be a normalized absolute path', path);
let current = parse(path).root;
for (const part of path.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part);
try { const st = await fs.lstat(current); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'directory path contains a symlink or non-directory', current); }
catch (e) { if (e.code !== 'ENOENT' || !create) throw e; await fs.mkdir(current, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST') throw err; }); const st = await fs.lstat(current); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'unsafe directory', current); }
}
}
async function readPrivate(file) {
const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return JSON.parse(await fd.readFile('utf8')); } finally { await fd.close(); }
}
async function privateDir(path) { const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
function validateRegistry(doc, file) {
if (!doc || doc.version !== 1 || typeof doc.instance !== 'string' || !doc.orgs || typeof doc.orgs !== 'object' || Array.isArray(doc.orgs)) fail('org-mismatch', 'invalid registry', file);
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string') fail('org-mismatch', 'invalid registry entry', file); }
return doc;
}
export async function readRegistry({ configDir }) {
await safeDir(configDir); await privateDir(configDir); const file = join(configDir, 'org-runtime.json'); let doc;
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e.message}`, file); }
return validateRegistry(doc, file);
}
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
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: {} };
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
const result = await fn(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 }); }
}
return result;
} finally { await fd.close(); await fs.unlink(lock); }
}
// The broker's existing organization writer is synchronous. It shares the
// registry lock with the async service and refuses contention rather than
// blocking the event loop. Legacy configs without a registry stay unchanged.
export function withRegistryWriter(configDir, fn) {
const file = join(configDir, 'org-runtime.json');
const lock = join(configDir, 'org-runtime.lock'); let lockFd;
try { lockFd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'registry lock is held', lock); throw e; }
const release = () => { closeSync(lockFd); unlinkSync(lock); };
let pending = false;
try {
let fd, registry = null;
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code !== 'ENOENT') throw e; }
if (fd !== undefined) {
try {
const dir = lstatSync(configDir);
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', configDir);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file);
registry = validateRegistry(JSON.parse(readFileSync(fd, 'utf8')), file);
} finally { closeSync(fd); }
}
const publish = () => {
const temp = join(configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
try { writeFileSync(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); renameSync(temp, file); }
finally { try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; } }
};
const result = fn(registry, publish);
if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
pending = true;
return Promise.resolve(result).finally(release);
}
return result;
} finally { if (!pending) release(); }
}
export async function reserveOrg(ctx, id) {
const root = orgRoot(ctx.dataRoot, id);
return withRegistry(ctx, async registry => {
if (Object.hasOwn(registry.orgs, id)) fail('org-exists', 'organization already reserved', root);
for (const entry of Object.values(registry.orgs)) if (entry.root === root || root.startsWith(entry.root + '/') || entry.root.startsWith(root + '/')) fail('org-overlap', 'organization roots overlap', root);
await safeDir(ctx.dataRoot); await privateDir(ctx.dataRoot);
await safeDir(dirname(root), true); await privateDir(dirname(root));
try { await fs.mkdir(root, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') fail('org-exists', 'organization root already exists', root); throw e; }
await fs.writeFile(join(root, '.mosaic-org.json'), JSON.stringify({ instance: ctx.instance, id }) + '\n', { mode: 0o600, flag: 'wx' });
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1 }; registry.orgs[id] = entry; return { ...entry };
});
}
export async function resolveOrg(ctx, id) {
const root = orgRoot(ctx.dataRoot, id); const registry = await readRegistry(ctx); const entry = registry?.orgs?.[id];
if (!entry) fail('org-unknown', 'unknown organization');
if (registry.instance !== ctx.instance || entry.root !== root) fail('org-mismatch', 'organization binding mismatch', root);
try { await safeDir(root); await privateDir(ctx.dataRoot); await privateDir(dirname(root)); const st = await fs.lstat(root); if (st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700) fail('org-mismatch', 'unsafe organization root', root); const marker = await readPrivate(join(root, '.mosaic-org.json')); if (marker.instance !== ctx.instance || marker.id !== id || Object.keys(marker).sort().join(',') !== 'id,instance') fail('org-mismatch', 'organization marker mismatch', root); }
catch (e) { if (e instanceof OrgError) throw e; fail('org-mismatch', 'cannot verify organization root or marker', root); }
return { id, root, state: entry.state };
}
export async function activateOrg(ctx, id) {
return withRegistry(ctx, async registry => {
const found = await resolveOrg(ctx, id);
if (found.state !== 'reserved') fail('org-exists', 'organization already active');
// An existing business document must join the same digest fence as a
// document created by the organization service. Some reservations have
// no document yet; preserve the registry's existing activation contract.
const dir = join(ctx.configDir, 'businesses'), file = join(dir, `${id}.json`);
try {
await safeDir(dir);
const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const st = await fd.stat();
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o022)) fail('org-mismatch', 'unsafe organization document', file);
registry.orgs[id].digest = createHash('sha256').update(await fd.readFile()).digest('hex');
} finally { await fd.close(); }
} catch (e) { if (e.code !== 'ENOENT') throw e; }
registry.orgs[id].state = 'active';
return { ...registry.orgs[id] };
});
}
// Serialize bus launch with organization lifecycle operations.
// A leftover lock is never guessed stale; the operator must inspect it.
export async function withOrgLifecycle(ctx, id, fn) {
idCheck(id); await safeDir(ctx.configDir); await privateDir(ctx.configDir);
const path = join(ctx.configDir, `org-lifecycle-${id}.lock`); let fd;
try { fd = await fs.open(path, 'wx', 0o600); }
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'organization lifecycle lock is held; inspect the lock and resume'); throw e; }
try { await fd.writeFile(JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + '\n'); return await fn(); }
finally { await fd.close(); await fs.unlink(path); }
}
// Lifecycle locks carry {pid, at}; registry locks are empty and cannot be
// attributed safely. Only competing clear operations need this short fence:
// a new lifecycle holder cannot acquire the existing lock until it is removed.
function inspectRecoveryFence(path) {
let fd;
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code === 'ENOENT') return null; return { path, unreadable: true }; }
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) return { path, unreadable: true };
let holder; try { holder = JSON.parse(readFileSync(fd, 'utf8')); } catch { return { path, unreadable: true }; }
if (!Number.isSafeInteger(holder?.pid) || holder.pid <= 0 || typeof holder.startTime !== 'string' || !/^\d+$/.test(holder.startTime)) return { path, unreadable: true };
let status; try { status = processState(holder.pid); } catch { return { path, pid: holder.pid, startTime: holder.startTime, alive: null }; }
return { path, pid: holder.pid, startTime: holder.startTime, alive: status.alive && status.startTime === holder.startTime, dev: st.dev, ino: st.ino };
} finally { closeSync(fd); }
}
export async function orgLock(ctx, id, { clear = false } = {}) {
idCheck(id); await safeDir(ctx.configDir); await privateDir(ctx.configDir);
const path = join(ctx.configDir, `org-lifecycle-${id}.lock`);
const guard = `${path}.clear`; let guardFd, self;
if (clear) {
self = processState(process.pid);
if (!self.alive || !self.startTime) fail('org-mismatch', 'clearer process identity is uncertain', guard);
for (;;) {
try { guardFd = openSync(guard, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); break; }
catch (e) {
if (e.code !== 'EEXIST') throw e;
const holder = inspectRecoveryFence(guard);
if (!holder) continue;
if (holder.unreadable) fail('org-locked', `unreadable recovery fence ${guard}; remove it by hand`, guard);
if (holder.alive !== false) fail('org-locked', `lock recovery is already in progress by pid ${holder.pid}`, guard);
let current; try { current = lstatSync(guard); } catch (err) { if (err.code === 'ENOENT') continue; throw err; }
if (current.dev !== holder.dev || current.ino !== holder.ino) fail('org-locked', 'recovery fence changed during recovery', guard);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
try { renameSync(guard, `${guard}.stale-${stamp}-${randomBytes(4).toString('hex')}`); }
catch (err) { if (err.code === 'ENOENT') continue; throw err; }
}
}
}
try {
if (guardFd !== undefined) writeFileSync(guardFd, JSON.stringify({ pid: process.pid, startTime: self.startTime }) + '\n');
const fence = !clear ? inspectRecoveryFence(guard) : null;
const reportFence = result => { if (fence) { const { dev, ino, ...visible } = fence; result.fence = visible; } return result; };
let fd;
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code === 'ENOENT') return reportFence(clear ? { held: false, cleared: false } : { held: false }); throw e; }
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) fail('org-mismatch', 'unsafe lifecycle lock', path);
let holder; try { holder = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('org-mismatch', 'unreadable lifecycle lock', path); }
if (!Number.isSafeInteger(holder?.pid) || holder.pid <= 0 || typeof holder.at !== 'string' || !Number.isFinite(Date.parse(holder.at))) fail('org-mismatch', 'invalid lifecycle lock holder', path);
let status; try { status = processState(holder.pid); } catch { fail('org-mismatch', 'lifecycle lock holder is uncertain', path); }
const result = { held: true, pid: holder.pid, start: holder.at, alive: status.alive };
if (clear) {
if (status.alive) fail('org-locked', `lifecycle lock held by pid ${holder.pid} since ${holder.at} (alive)`, path);
const current = lstatSync(path);
if (current.dev !== st.dev || current.ino !== st.ino) fail('org-locked', 'lifecycle lock changed during recovery', path);
unlinkSync(path); result.cleared = true;
}
return reportFence(result);
} finally { closeSync(fd); }
} finally {
if (guardFd !== undefined) {
const owned = fstatSync(guardFd);
try {
const current = lstatSync(guard);
if (owned.dev !== current.dev || owned.ino !== current.ino) fail('org-locked', 'recovery fence changed during recovery', guard);
unlinkSync(guard);
} finally { closeSync(guardFd); }
}
}
}
// Old path of packages/organization/src/org-registry.mjs (rename design r2, stage B).
export * from "../../organization/src/org-registry.mjs";
+2 -386
View File
@@ -1,386 +1,2 @@
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateBusinessDocument, httpsUrl } from './business.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
import { loadInstalled } from '../../plugins/src/loader.mjs';
import { BINDING_NAME } from '../../discord/src/binding.mjs';
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
const same = (a, b) => !!a && !!b && ['provider', 'iss', 'sub'].every(k => a[k] === b[k]);
const digest = text => createHash('sha256').update(text).digest('hex');
const encode = doc => JSON.stringify(doc, null, 2) + '\n';
const projectPath = (root, project) => join(root, 'projects', project);
function authorize(ctx) { if (!ctx || typeof ctx.instance !== 'string' || !/^[a-z][a-z0-9]{1,15}$/.test(ctx.instance) || ctx.instance === 'dev' || !same(ctx.principal, owner)) fail('org-forbidden', 'authenticated local development owner required'); }
function checkId(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid id'); }
async function privateRead(file) { const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } finally { await fd.close(); } }
async function atomic(file, content, exclusive = false) {
const tmp = join(dirname(file), `.org-write-${randomBytes(12).toString('hex')}`);
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } finally { await fs.rm(tmp, { force: true }); }
}
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; } const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
async function receipt(root, op, org, principal, fields = {}) {
const result = { op, org, at: new Date().toISOString(), subject: { ...principal }, ...fields };
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe receipts', file); await fd.writeFile(JSON.stringify(result) + '\n'); } finally { await fd.close(); } return result;
}
async function receipts(root) {
try { const text = await privateRead(join(root, 'receipts.jsonl')); return text.trim().split('\n').map(line => JSON.parse(line)); }
catch { fail('org-mismatch', 'organization receipts cannot be read'); }
}
function normalizeDoc(ctx, id, input, existing) {
if (!input || input.id !== id) fail('org-invalid', 'document id must match organization id', '$.id');
const doc = structuredClone(input); const root = orgRoot(ctx.dataRoot, id);
for (const [project, entry] of Object.entries(doc.projects ?? {})) {
checkId(project); if (!entry || entry.kind === 'library') fail('org-invalid', 'configured projects must be Git projects', `$.projects.${project}`);
const expected = projectPath(root, project), placeholder = `/__mosaic_org__/${id}/projects/${project}`;
if (entry.root !== expected && entry.root !== placeholder) fail('org-invalid', 'project root must be derived from the organization', `$.projects.${project}.root`);
if (entry.repo !== undefined && entry.repo !== expected) fail('org-invalid', 'project repo must be derived from the organization', `$.projects.${project}.repo`);
const previous = existing?.projects?.[project];
if (entry.git && JSON.stringify(entry.git) !== JSON.stringify(previous?.git)) fail('org-invalid', 'project git binding is established by repo init', `$.projects.${project}.git`);
if (previous?.git && JSON.stringify(entry.git) !== JSON.stringify(previous.git)) fail('org-invalid', 'initialized git binding cannot be changed', `$.projects.${project}.git`);
entry.root = expected; if (entry.repo !== undefined) entry.repo = expected;
}
if (existing) for (const [p, entry] of Object.entries(existing.projects)) if (entry.git && !doc.projects?.[p]) fail('org-invalid', 'initialized project cannot be removed', `$.projects.${p}`);
return doc;
}
// Presets are supplied by installed modules; reject malformed or duplicate names
// rather than silently choosing a different module's configuration.
async function discoverPresets() {
const presets = new Map();
for (const kind of ['harness', 'model-provider']) {
const { modules, refused } = await loadInstalled(kind);
if (refused.length) fail('org-invalid', 'installed plugin could not be loaded');
for (const { module } of modules) {
if (module.orgPresets === undefined) continue;
if (typeof module.orgPresets !== 'function') fail('org-invalid', 'invalid preset provider');
const list = await module.orgPresets();
if (!Array.isArray(list)) fail('org-invalid', 'invalid preset provider');
for (const p of list) {
if (!p || typeof p.name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(p.name) || presets.has(p.name) ||
typeof p.label !== 'string' || !p.label || typeof p.description !== 'string' || !p.description ||
!Array.isArray(p.thinking?.levels) || !p.thinking.levels.length ||
p.thinking.levels.some(v => typeof v !== 'string' || !/^[a-z][a-z0-9-]*$/.test(v)) ||
new Set(p.thinking.levels).size !== p.thinking.levels.length || !p.thinking.levels.includes(p.thinking.default) ||
typeof p.available !== 'function' || typeof p.overlay !== 'function') fail('org-invalid', 'invalid or duplicate preset');
presets.set(p.name, p);
}
}
}
return presets;
}
export async function orgPresets({ env = process.env } = {}) {
const presets = await discoverPresets();
return Promise.all([...presets.values()].map(async p => {
const availability = await p.available({ env });
if (!availability || typeof availability.ok !== 'boolean' || (!availability.ok && (typeof availability.reason !== 'string' || !availability.reason))) fail('org-invalid', 'invalid preset availability');
return { name: p.name, label: p.label, description: p.description, thinking: structuredClone(p.thinking), available: availability.ok ? { ok: true } : { ok: false, reason: availability.reason } };
}));
}
export async function templateRender(id, { fixture, preset, thinking } = {}) {
if (fixture !== undefined && fixture !== 'local') fail('org-invalid', 'unknown fixture');
if (fixture !== undefined && preset !== undefined) fail('org-invalid', 'choose fixture or preset');
if (fixture === 'local') preset = 'fixture';
checkId(id);
const doc = JSON.parse(await fs.readFile(new URL('./org-template.json', import.meta.url), 'utf8'));
doc.id = id; doc.projects.work.root = `/__mosaic_org__/${id}/projects/work`;
if (preset !== undefined) {
if (typeof preset !== 'string') fail('org-invalid', 'unknown preset');
const p = (await discoverPresets()).get(preset);
if (!p) fail('org-invalid', 'unknown preset');
const level = thinking === undefined ? p.thinking.default : thinking;
if (!p.thinking.levels.includes(level)) fail('org-invalid', 'unknown thinking level');
const overlay = await p.overlay({ thinking: level });
if (!overlay || typeof overlay !== 'object' || !overlay.plugins || !overlay.launch || !overlay.assistantVars) fail('org-invalid', 'invalid preset overlay');
doc.plugins = structuredClone(overlay.plugins); doc.launch = structuredClone(overlay.launch);
doc.roles.assistant.vars = structuredClone(overlay.assistantVars);
if (overlay.vars !== undefined) doc.vars = structuredClone(overlay.vars);
} else if (thinking !== undefined) fail('org-invalid', 'thinking requires a preset');
return doc;
}
export async function validateOrg(doc) {
try {
checkId(doc?.id); validateBusinessDocument(doc, join('/businesses', `${doc.id}.json`), { rolesDir });
for (const [project, entry] of Object.entries(doc.projects)) {
const suffix = `/org-runtime/${doc.id}/projects/${project}`;
const placeholder = `/__mosaic_org__/${doc.id}/projects/${project}`;
if (entry.kind === 'library' || (entry.root !== placeholder && !entry.root.endsWith(suffix))) fail('org-invalid', 'project root must be derived from the organization', `$.projects.${project}.root`);
if (entry.repo !== undefined && entry.repo !== entry.root) fail('org-invalid', 'project repo must match its root', `$.projects.${project}.repo`);
}
return { ok: true, errors: [] };
}
catch (e) {
const match = /(?:^|[ /])(?:projects|roles|arbiters)\.([a-z0-9._-]+(?:\.[a-z0-9._-]+)*)/.exec(e.message);
const field = /(?:business file requires |business file ")"?([a-zA-Z][a-zA-Z0-9]*)/.exec(e.message);
const path = match ? '$.' + match[0].trim().replace(/^.*\//, '') : field ? '$.' + field[1] : /business id/.test(e.message) ? '$.id' : /business human/.test(e.message) ? '$.human' : '$';
return { ok: false, errors: [{ path: e.path ?? path, message: e.message }] };
}
}
async function checked(doc) { const result = await validateOrg(doc); if (!result.ok) fail('org-invalid', result.errors[0].message, result.errors[0].path); }
async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
const found = await resolveOrg(ctx, id);
if (found.state === 'archived' && !includeArchived) fail('org-archived', 'organization is archived');
if (found.state !== 'active' && !(includeArchived && found.state === 'archived')) fail('org-unavailable', 'organization is not active');
const principal = JSON.parse(await privateRead(join(found.root, 'owner.json'))); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
// From here the caller is the verified owner. A failure carries that proof so a list may
// show this org as broken to its owner; earlier failures prove nothing and stay hidden.
try {
const entry = (await readRegistry(ctx)).orgs[id]; const raw = await privateRead(join(ctx.configDir, 'businesses', `${id}.json`));
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
const doc = JSON.parse(raw); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
} catch (e) { if (e && typeof e === 'object') e.ownerVerified = true; throw e; }
}
// Readers do not take the writer lock. Require unchanged registry snapshots and
// no in-progress publication on either side of the read; retry a raced write.
async function readCurrent(ctx, id, opts) {
const locked = async () => { try { await fs.lstat(join(ctx.configDir, 'org-runtime.lock')); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
for (let n = 0; n < 200; n++) {
if (await locked()) { await new Promise(r => setTimeout(r, 10)); continue; }
const before = JSON.stringify(await readRegistry(ctx));
let result, error;
try { result = await readCurrentUnlocked(ctx, id, opts); } catch (e) { error = e; }
const after = JSON.stringify(await readRegistry(ctx));
if (!(await locked()) && before === after) { if (error) throw error; return result; }
await new Promise(r => setTimeout(r, 10));
}
fail('org-locked', 'registry changed or was locked during organization read');
}
export async function createOrg(ctx, id, input) {
authorize(ctx); checkId(id); if (input?.human !== ctx.principal.sub) fail('org-forbidden', 'document human must name the local owner', '$.human');
const doc = normalizeDoc(ctx, id, input); await checked(doc);
await readRegistry(ctx);
const businessDir = join(ctx.configDir, 'businesses'); await ensurePrivateDir(businessDir);
const file = join(businessDir, `${id}.json`); try { await fs.lstat(file); fail('org-exists', 'business file already exists', file); } catch (e) { if (e.code !== 'ENOENT') throw e; }
const entry = await reserveOrg(ctx, id); const raw = encode(doc);
await atomic(join(entry.root, 'owner.json'), encode(ctx.principal), true); await atomic(file, raw, true);
const notify = join(entry.root, 'notify'); await ensurePrivateDir(notify);
const bindingDir = join(notify, id); await ensurePrivateDir(bindingDir);
await atomic(join(bindingDir, 'notify.json'), encode({ notifyVersion: 1, binding: null }), true);
const result = await receipt(entry.root, 'org.create', id, ctx.principal, { root: entry.root, revision: entry.revision, notifications: 'none' });
await withRegistry(ctx, async registry => { const found = await resolveOrg(ctx, id); if (found.state !== 'reserved') fail('org-mismatch', 'reservation changed'); registry.orgs[id].digest = digest(raw); registry.orgs[id].state = 'active'; }); return result;
}
export async function showOrg(ctx, id, opts = {}) { authorize(ctx); const { root, revision, doc, principal, state } = await readCurrent(ctx, id, opts); return { id, root, revision, doc, principal, state }; }
export async function notificationShow(ctx, id) {
authorize(ctx); const current = await readCurrent(ctx, id);
for (const path of [join(current.root, 'notify'), join(current.root, 'notify', id)]) {
const st = await fs.lstat(path);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe notifier directory');
}
const file = join(current.root, 'notify', id, 'notify.json');
const st = await fs.lstat(file);
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration');
const binding = JSON.parse(await privateRead(file));
if (!binding || Object.keys(binding).sort().join(',') !== 'binding,notifyVersion' || binding.notifyVersion !== 1 || !(binding.binding === null || typeof binding.binding === 'string' && BINDING_NAME.test(binding.binding))) fail('org-mismatch', 'invalid notifier configuration');
const record = (await receipts(current.root)).find(r => r.op === 'org.create' && r.org === id && same(r.subject, current.principal));
if (!record || record.notifications !== 'none') fail('org-mismatch', 'notification choice receipt unavailable');
return { ...record, currentNotifications: binding.binding === null ? 'none' : 'configured' };
}
export async function updateOrg(ctx, id, input, { ifRevision } = {}) {
authorize(ctx); return withRegistry(ctx, async registry => {
const current = await readCurrentUnlocked(ctx, id); if (!Number.isSafeInteger(ifRevision) || ifRevision !== current.revision) fail('revision-conflict', 'organization revision changed');
const doc = normalizeDoc(ctx, id, input, current.doc); if (doc.human !== ctx.principal.sub) fail('org-forbidden', 'document human must name the local owner'); await checked(doc);
const raw = encode(doc), revision = current.revision + 1; await atomic(join(ctx.configDir, 'businesses', `${id}.json`), raw); registry.orgs[id].digest = digest(raw); registry.orgs[id].revision = revision;
return receipt(current.root, 'org.update', id, ctx.principal, { root: current.root, revision });
});
}
function options({ mode, remote, acceptLocalLossRisk }) {
if (mode === 'local-only') { if (acceptLocalLossRisk !== true || remote !== undefined) fail('org-risk', 'local-only requires explicit loss-risk acceptance and no remote'); return { mode, riskAccepted: true }; }
if (mode === 'remote') { if (acceptLocalLossRisk || typeof remote !== 'string') fail('org-invalid', 'remote mode requires a remote URL'); try { httpsUrl(remote, 'remote', { repo: true }); } catch (e) { fail('org-invalid', e.message); } return { mode, remote }; }
fail('org-invalid', 'select local-only or remote');
}
async function git(root, args) {
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' };
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git failed: ${err.trim()}`))); });
}
async function initGit(root) { await fs.mkdir(root, { mode: 0o700 }); await git(root, ['init', '--quiet', '--template=']); await git(root, ['commit', '--quiet', '--allow-empty', '-m', 'Genesis']); return git(root, ['rev-parse', 'HEAD']); }
async function setup(ctx, id, project, opts) {
authorize(ctx); const selection = options(opts); if (project !== null) checkId(project);
return withOrgLifecycle(ctx, id, async () => {
const current = await withRegistry(ctx, async () => {
const current = await readCurrentUnlocked(ctx, id); if (project !== null && !Object.hasOwn(current.doc.projects, project)) fail('org-unknown', 'unknown project');
const op = project === null ? 'org.queue.init' : 'org.project.repo.init';
if ((await receipts(current.root)).some(r => r.op === op && (project === null || r.project === project))) fail('org-exists', 'repository was already initialized; inspect its receipt');
return current;
}, { write: false });
const parent = project === null ? current.root : join(current.root, 'projects'); if (project !== null) await ensurePrivateDir(parent);
const root = project === null ? join(current.root, 'checkout') : projectPath(current.root, project);
const commit = await initGit(root); const fields = { ...selection, root, commit, ...(project === null ? {} : { project }) };
return withRegistry(ctx, async registry => {
const latest = await readCurrentUnlocked(ctx, id);
if (latest.revision !== current.revision) fail('revision-conflict', 'organization changed during setup; inspect the initialized repository before retrying');
if (project !== null) { latest.doc.projects[project].git = selection.mode === 'remote' ? { mode: 'remote', remote: selection.remote } : { mode: 'local-only' }; await checked(latest.doc); const raw = encode(latest.doc); await atomic(join(ctx.configDir, 'businesses', `${id}.json`), raw); registry.orgs[id].digest = digest(raw); registry.orgs[id].revision++; }
return receipt(current.root, project === null ? 'org.queue.init' : 'org.project.repo.init', id, ctx.principal, fields);
});
});
}
export async function queueInit(ctx, id, opts = {}) { return setup(ctx, id, null, opts); }
export async function projectRepoInit(ctx, id, project, opts = {}) { return setup(ctx, id, project, opts); }
async function showSetup(ctx, id, project) {
authorize(ctx); const current = await readCurrent(ctx, id); if (project !== null) { checkId(project); if (!Object.hasOwn(current.doc.projects, project)) fail('org-unknown', 'unknown project'); }
const records = await receipts(current.root);
const op = project === null ? 'org.queue.init' : 'org.project.repo.init'; const item = records.findLast(r => r.op === op && (project === null || r.project === project));
const root = project === null ? join(current.root, 'checkout') : projectPath(current.root, project);
if (!item || item.root !== root || !same(item.subject, current.principal)) fail('org-unavailable', 'setup receipt unavailable');
if (project !== null) { const parent = await fs.lstat(join(current.root, 'projects')); if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid() || (parent.mode & 0o077)) fail('org-mismatch', 'unsafe projects directory'); }
const st = await fs.lstat(root); const dotgit = await fs.lstat(join(root, '.git')); if (!st.isDirectory() || st.isSymbolicLink() || !dotgit.isDirectory() || dotgit.isSymbolicLink()) fail('org-mismatch', 'unsafe git repository');
await git(root, ['rev-parse', '--verify', 'HEAD']); return item;
}
export async function queueShow(ctx, id) { return showSetup(ctx, id, null); }
export async function projectRepoShow(ctx, id, project) { return showSetup(ctx, id, project); }
// Archive and restore share the bus-start/delete fence, but do not hold the
// registry lock while waiting for the bus host to stop.
async function lifecycleChange(ctx, id, { ifRevision, busStop }, operation) {
authorize(ctx); checkId(id);
if (operation === 'archive' && typeof busStop !== 'function') fail('org-invalid', 'archive needs a bus stop function');
return withOrgLifecycle(ctx, id, async () => {
const expected = operation === 'archive' ? 'active' : 'archived';
const inspect = async () => {
const current = await readCurrentUnlocked(ctx, id, { includeArchived: true });
if (current.state !== expected) fail(current.state === 'archived' ? 'org-archived' : 'org-invalid', `organization is not ${expected}`);
if (!Number.isSafeInteger(ifRevision) || current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed');
return current;
};
await withRegistry(ctx, inspect, { write: false });
const bus = operation === 'archive' ? await busStop(ctx, id) : null;
if (operation === 'archive' && (!bus || typeof bus !== 'object' || typeof bus.stopped !== 'boolean')) fail('org-unavailable', 'bus stop outcome is uncertain');
return withRegistry(ctx, async registry => {
const current = await inspect();
if (!Number.isSafeInteger(current.revision + 1)) fail('org-mismatch', 'organization revision cannot be incremented');
await deleteSafeRoot(ctx.dataRoot);
const record = { op: `org.${operation}`, org: id, actor: { ...ctx.principal }, time: new Date().toISOString(), revision: current.revision + 1, bus };
let fd;
try { fd = await fs.open(join(ctx.dataRoot, 'org-lifecycle.log'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600); }
catch (e) { if (e.code === 'ENXIO') fail('org-mismatch', 'unsafe organization lifecycle log'); throw e; }
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe organization lifecycle log'); await fd.writeFile(JSON.stringify(record) + '\n'); }
finally { await fd.close(); }
registry.orgs[id].state = operation === 'archive' ? 'archived' : 'active';
registry.orgs[id].revision = record.revision;
return { ...record, outcome: 'done', step: operation };
});
});
}
export async function archiveOrg(ctx, id, opts = {}) { return lifecycleChange(ctx, id, opts, 'archive'); }
export async function restoreOrg(ctx, id, opts = {}) { return lifecycleChange(ctx, id, opts, 'restore'); }
// Deletion keeps a durable marker in the registry and a receipt beside the
// moved data. It never opens the contents of organization secret trees.
async function deleteInspect(path, { secret = false } = {}) {
let count = 0;
async function visit(p) {
const st = await fs.lstat(p);
if (st.isSymbolicLink() || (!st.isDirectory() && !st.isFile() && !st.isSocket())) fail('org-mismatch', 'unsafe owned path');
if (secret && (st.uid !== process.getuid() || (st.mode & 0o077) || (!st.isDirectory() && !st.isFile()))) fail('org-mismatch', 'unsafe organization secret directory');
if (st.isDirectory()) for (const name of await fs.readdir(p)) { count++; await visit(join(p, name)); }
}
try { await fs.lstat(path); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
await visit(path); return count;
}
async function deleteSafeRoot(root) {
if (typeof root !== 'string' || !root.startsWith('/') || resolve(root) !== root) fail('org-mismatch', 'invalid data root');
let path = '/';
for (const part of root.split('/').filter(Boolean)) {
path = join(path, part); const st = await fs.lstat(path);
if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'unsafe data root');
if (path === root && (st.uid !== process.getuid() || (st.mode & 0o077))) fail('org-mismatch', 'unsafe data root');
}
}
async function deleteParents(base, relative) {
let path = base;
const parts = relative.split('/'); parts.pop();
for (const part of parts) {
path = join(path, part);
try { const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe owned path parent'); }
catch (e) { if (e.code === 'ENOENT') return false; throw e; }
}
return true;
}
// busStop comes from the caller (the CLI's busStopForDelete), so business code does not import the bus host.
export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {}) {
authorize(ctx); checkId(id);
if (typeof busStop !== 'function') fail('org-invalid', 'deletion needs a bus stop function');
if (confirm !== id) fail('org-confirm', 'confirmation must match the organization ID');
return withOrgLifecycle(ctx, id, async () => {
const deletion = await withRegistry(ctx, async registry => {
const entry = registry.orgs[id];
if (!entry) fail('org-unknown', 'unknown organization');
if (entry.root !== orgRoot(ctx.dataRoot, id)) fail('org-mismatch', 'organization binding mismatch');
if (entry.state === 'deleting') {
if (!entry.deletion || !same(entry.deletion.actor, ctx.principal) || !/^[a-zA-Z0-9._-]+$/.test(entry.deletion.folder) || !entry.deletion.folder.startsWith(`${id}-`)) fail('org-mismatch', 'invalid deletion marker');
return entry.deletion;
}
const current = await readCurrentUnlocked(ctx, id, { includeArchived: true });
if (!Number.isSafeInteger(ifRevision) || current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed');
const marker = { actor: { ...ctx.principal }, time: new Date().toISOString(), revision: current.revision, folder: `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomBytes(4).toString('hex')}` };
entry.state = 'deleting'; entry.deletion = marker;
return marker;
});
const deleted = join(ctx.dataRoot, 'deleted'), destination = join(deleted, deletion.folder), receiptFile = join(destination, 'receipt.json');
let registryRemoved = false, result, phase = 'prepare';
try {
await deleteSafeRoot(ctx.dataRoot);
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
try { result = JSON.parse(await privateRead(receiptFile)); }
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
if (!result.bus) {
phase = 'bus';
result.bus = await busStop(ctx, id);
await atomic(receiptFile, encode(result));
}
phase = 'inspect';
await withRegistry(ctx, async registry => {
const entry = registry.orgs[id];
if (!entry || entry.state !== 'deleting' || entry.deletion?.folder !== deletion.folder) fail('org-mismatch', 'deletion marker changed');
const owned = [
{ base: ctx.dataRoot, rel: `org-runtime/${id}`, to: 'runtime', required: true },
{ base: ctx.configDir, rel: `businesses/${id}.json`, to: 'organization.json', required: true },
{ base: ctx.configDir, rel: `businesses/.history/${id}`, to: 'history' },
...['agents', 'grants', 'hosts'].map(kind => ({ base: ctx.configDir, rel: `secrets/${kind}/${id}`, to: `secrets-${kind}`, secret: true })),
];
// Inspect all sources and destinations before moving the first one.
for (const item of owned) {
item.from = join(item.base, item.rel); item.dest = join(destination, item.to);
const parents = await deleteParents(item.base, item.rel);
item.count = parents ? await deleteInspect(item.from, { secret: item.secret }) : null;
const prior = result.moves.find(m => m.from === item.from);
const destCount = await deleteInspect(item.dest, { secret: item.secret });
if (prior && (item.count !== null || destCount === null)) fail('org-mismatch', 'moved path does not match receipt');
if (!prior && item.count !== null && destCount !== null) fail('org-mismatch', 'deletion destination already exists');
if (!prior && item.count === null && destCount !== null) item.recovered = true;
if (!prior && item.count === null && destCount === null && item.required) fail('org-mismatch', 'required organization path is missing');
if (item.recovered) item.count = destCount;
}
phase = 'move';
for (const item of owned) {
if (result.moves.some(m => m.from === item.from) || item.count === null) continue;
if (!item.recovered) await fs.rename(item.from, item.dest);
result.moves.push({ from: item.from, to: item.dest });
if (item.secret) result.secrets.push({ path: item.from, entryCount: item.count });
await atomic(receiptFile, encode(result));
}
phase = 'registry';
const log = join(deleted, 'deleted.log');
const fd = await fs.open(log, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe deletion log');
const lines = (await fd.readFile('utf8')).split('\n');
if (!lines.some(line => { try { return JSON.parse(line).destination === destination; } catch { return false; } })) await fd.writeFile(JSON.stringify({ org: id, destination, time: deletion.time, actor: deletion.actor, revision: deletion.revision }) + '\n');
} finally { await fd.close(); }
result.registry = 'removal pending'; await atomic(receiptFile, encode(result));
delete registry.orgs[id];
});
registryRemoved = true; result.registry = 'removed'; await atomic(receiptFile, encode(result)); return result;
} catch (e) {
e.deleteStep = phase; if (result) e.deleteReceipt = result;
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${receiptFile}`; throw e; }
e.message += `; deletion remains in progress. Inspect ${destination}/receipt.json and rerun mosaic org delete ${id} --confirm ${id}`;
throw e;
}
});
}
// Old path of packages/organization/src/org-service.mjs (rename design r2, stage B).
export * from "../../organization/src/org-service.mjs";
+2 -444
View File
@@ -1,444 +1,2 @@
// Trusted host write path (REQ-ROLE-3). Callers supply a verified actor;
// neither this module nor migration authenticates a new channel.
import {
constants, openSync, closeSync, fstatSync, readFileSync, writeFileSync,
fsyncSync, lstatSync, mkdirSync, renameSync, rmSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
import { requireId, sha256, canonicalJson } from "./util.mjs";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
import { withRegistryWriter } from "./org-registry.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
super(message);
this.code = code;
}
}
const fail = (code, message) => {
throw new OrganizationError(code, message);
};
function regular(file) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o022)) {
fail("organization-refused", "organization file must be regular, owned and not group/world writable");
}
return readFileSync(fd, "utf8");
} finally {
if (fd !== undefined) closeSync(fd);
}
}
function directory(path) {
try {
mkdirSync(path, { mode: 0o700 });
syncDir(dirname(path));
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
const st = lstatSync(path);
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077)) {
fail("organization-refused", "organization evidence directory is unsafe");
}
}
function syncDir(dir) {
const fd = openSync(dir, constants.O_RDONLY | constants.O_DIRECTORY);
try {
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function durable(file, bytes) {
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
const fd = openSync(file, flags, 0o600);
try {
writeFileSync(fd, bytes);
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function changes(old, next, path = "") {
if (canonicalJson(old) === canonicalJson(next)) return [];
const objects = old && next && typeof old === "object" && typeof next === "object";
if (objects && !Array.isArray(old) && !Array.isArray(next)) {
const keys = [...new Set([...Object.keys(old), ...Object.keys(next)])];
return keys.flatMap(k => changes(old[k], next[k], path ? path + "." + k : k));
}
return [{
path, old: old ?? null, new: next ?? null,
oldPresent: old !== undefined, newPresent: next !== undefined,
}];
}
// P5/Q222: every writer protects per-model-key quotas, including removal/reset.
function modelQuotaFence(old, document, actor) {
const keys = doc => Object.fromEntries(Object.entries(doc?.launch?.limits?.tokens?.keys ?? {})
.filter(([key, entry]) => isDailyModelQuota(key, entry)));
if (canonicalJson(keys(old)) === canonicalJson(keys(document))) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([instance, entry]) => [instance, `${entry?.definition}.${instance}`]);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
fail("organization-model-quota-human-only");
}
// P5: the optional queue reader and polling setting are human changes even on
// the tester organization path. This guards every writer, not only the UI verb.
function usageFence(old, document, actor) {
const setting = doc => ({ plugin: doc?.plugins?.usage ?? null, usage: doc?.usage ?? null });
if (canonicalJson(setting(old)) === canonicalJson(setting(document))) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([instance, entry]) => [instance, `${entry?.definition}.${instance}`]);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
fail("organization-usage-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.
function replyLimitsFence(old, document, actor) {
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
// The reason names each changed key and its value in force (the brief's M11).
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
const before = old.replyLimits, after = document?.replyLimits;
const named = plain(before) && plain(after)
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
}
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
// M11 fence refuses any actor but the human.
export function setReplyLimits(writer, id, args, actor) {
const replyLimits = checkReplyLimits(args?.replyLimits);
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
const { document } = writer.read(id);
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
}
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
// Keep the writer's established busy refusal when the shared registry lock
// is held, including when another document hold is already in progress.
function withWriterRegistry(fn) {
try { return withRegistryWriter(dir, fn); }
catch (e) { if (e.code === "org-locked") fail("organization-busy", "organization writer is busy"); throw e; }
}
// Reads and launches depend on the whole document, not transaction evidence.
function read(id) {
const file = businessFilePath(id, dir);
const bytes = regular(file);
const document = JSON.parse(bytes);
validateBusinessDocument(document, file, { rolesDir });
return { document, revision: sha256(bytes) };
}
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
// file that fails validation still yields its limits. { readable: false } when the file is missing,
// unreadable, not JSON or not an object.
function readRaw(id) {
try {
const document = JSON.parse(regular(businessFilePath(id, dir)));
return document !== null && typeof document === "object" && !Array.isArray(document)
? { readable: true, document } : { readable: false };
} catch {
return { readable: false };
}
}
function withLock(id, fn) {
const parent = join(dir, "businesses");
directory(evidence);
const home = join(evidence, id);
directory(home);
const lock = join(home, "write.lock");
try {
mkdirSync(lock, { mode: 0o700 });
durable(join(lock, "owner.json"), JSON.stringify({ pid: process.pid }));
syncDir(home);
} catch (e) {
if (e.code !== "EEXIST") throw e;
const st = lstatSync(lock);
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077)) {
fail("organization-refused", "unsafe writer lock");
}
let owner;
try {
owner = JSON.parse(regular(join(lock, "owner.json")));
} catch {
fail("organization-uncertain", "writer lock has no verifiable owner");
}
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1) fail("organization-uncertain");
try {
process.kill(owner.pid, 0);
fail("organization-busy");
} catch (x) {
if (x.code !== "ESRCH") throw x;
}
fail("organization-uncertain", "stale writer lock: confirm the old writer is gone before removing it");
}
const release = () => {
rmSync(lock, { recursive: true });
syncDir(home);
};
let pending = false;
try {
const out = fn({ home, parent });
if (out !== null && typeof out === "object" && typeof out.then === "function") {
pending = true;
return Promise.resolve(out).finally(release);
}
return out;
} finally {
if (!pending) release();
}
}
function log(id, event) {
let fd;
try {
const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW;
fd = openSync(logFile(id), flags, 0o600);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail("organization-refused");
writeFileSync(fd, JSON.stringify(event) + "\n");
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
}
// Only writes reconcile receipts. Uncertain evidence cannot stop a bus start or launch.
function recover(id) {
let text;
try {
text = regular(logFile(id));
} catch (e) {
if (e.code === "ENOENT") return;
throw e;
}
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
const entries = text.trim() ? text.trim().split("\n").map(JSON.parse) : [];
const pending = new Map();
for (const e of entries) {
if (e.state === "prepared") pending.set(e.transaction, e);
else pending.delete(e.transaction);
}
for (const e of pending.values()) {
const hash = sha256(regular(businessFilePath(id, dir)));
if (hash !== e.before && hash !== e.after) {
fail("organization-uncertain", "organization bytes do not match prepared receipt");
}
log(id, {
...e, state: hash === e.after ? "committed" : "aborted",
recovered: true, at: new Date().toISOString(),
});
}
}
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
// date, reason), never a value; the other setters pass none. context, when given, is logged with
// the change: row 194's hire and fire carry the chief's run and the decision there (D2.2).
// Row 239 (#1745): beforeWrite (a fire's or a delete's credential revoke) runs under the lock,
// after the revision check and the validations, so a refusal there revokes nothing. An error
// after it returns carries revoked: true, the lock's release included (duckworth-0165 C2), and the
// bus names it so the human reads that the tokens may already be revoked. beforeWrite must not
// write this business itself: it would find its own lock and refuse organization-busy.
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
// the old document, for a writer that builds what it publishes from what it read under the lock.
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
let revoked = false;
try {
return withWriterRegistry((registry, publishRegistry) => withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
const before = sha256(oldBytes);
const entry = registry?.orgs?.[id];
if (entry && (entry.state !== 'active' || entry.digest !== before || !Number.isSafeInteger(entry.revision + 1))) {
fail('org-mismatch', 'organization document and registry disagree');
}
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
const old = JSON.parse(oldBytes);
if (typeof document === "function") document = document(old);
guard(old, document);
replyLimitsFence(old, document, actor);
modelQuotaFence(old, document, actor);
usageFence(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
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
guard(old, written);
replyLimitsFence(old, written, actor);
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
if (beforeWrite) {
beforeWrite();
revoked = true;
}
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
directory(tx);
const previous = join(tx, "previous.json");
durable(previous, oldBytes);
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
barrier("prepared");
if (sha256(regular(file)) !== before) {
log(id, { ...event, state: "aborted" });
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
renameSync(candidate, file);
syncDir(parent);
if (entry) { entry.digest = after; entry.revision++; publishRegistry(); }
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
}));
} catch (e) {
if (revoked && e instanceof Error) e.revoked = true;
throw e;
}
}
// C2-04 M9: every organization setter preserves the whole project mapping, including library
// playgrounds and hybrid universes.
const projectsProtected = (old, document) => {
if (canonicalJson(document?.projects) !== canonicalJson(old.projects)) fail("organization-projects-protected");
};
function write(id, document, options) {
return commit(id, document, { ...options, guard: projectsProtected });
}
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
// addition: one new key under the managed product layout, a receipt naming the request and plan, and
// no other change. It compares both the document it built and the bytes it will publish. A stale
// revision refuses revision-conflict: the owner confirms a fresh preview, never a silent rebase.
function addProject(id, { revision, project, entry, receipt }, actor) {
const guard = (old, document) => {
const problem = additionProblem({ old, next: document, project, entry, receipt });
if (problem) fail("organization-project-add-refused", problem);
};
return commit(id, old => ({ ...old, projects: { ...old.projects, [project]: entry } }), {
revision, actor, guard,
detail: { verb: ADDITION_VERB, request: receipt?.request, plan_digest: receipt?.plan_digest, project },
});
}
// The only exception to the projects fence for an existing software mapping.
// Build from the locked document and check both the candidate and serialized bytes.
function setProjectRemote(id, { revision, project, remote }, actor) {
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
const problem = remoteProblem(old, document, project, remote);
if (problem) fail(problem);
};
return commit(id, old => {
remoteTarget(old, project, remote);
return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
}
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
function hold(id, fn) {
requireId(id, "organization id");
return withWriterRegistry(() => withLock(id, () => {
recover(id);
const { document, revision } = read(id);
const out = fn({ document, revision });
if (out !== null && typeof out === "object" && typeof out.then === "function") {
out.catch?.(() => {});
fail("organization-hold-sync", "a held check must be synchronous");
}
return out;
}));
}
// Like hold, but the lock stays on disk until an asynchronous effect settles, including on failure.
// The effect must not call an organization writer: it would find this same lock and refuse busy.
function holdAsync(id, fn) {
requireId(id, "organization id");
return withWriterRegistry(() => withLock(id, () => {
recover(id);
return fn(read(id));
}));
}
// The committed addition of this request and plan, if there is one. A prepared receipt whose
// `after` is the file's current hash counts: the rename landed and the commit line did not.
function findAddition(id, { request, plan_digest, project }) {
let text;
try {
text = regular(logFile(id));
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
const last = new Map();
for (const e of text.trim() ? text.trim().split("\n").map(JSON.parse) : []) last.set(e.transaction, e);
const hash = sha256(regular(businessFilePath(id, dir)));
for (const e of last.values()) {
const d = e.detail;
if (d?.verb !== ADDITION_VERB || d.request !== request || d.plan_digest !== plan_digest || d.project !== project) continue;
if (e.state === "committed" || (e.state === "prepared" && e.after === hash))
return { after: e.after, before: e.before, transaction: e.transaction };
}
return null;
}
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
function record(id, { actor, detail }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, () => {
recover(id);
const hash = sha256(regular(businessFilePath(id, dir)));
const transaction = randomUUID();
log(id, {
transaction, actor, business: id, before: hash, after: hash, changes: [], detail,
at: new Date().toISOString(), state: "committed",
});
return { revision: hash, transaction };
});
}
return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}
// Old path of packages/organization/src/organization.mjs (rename design r2, stage B).
export * from "../../organization/src/organization.mjs";
+2 -70
View File
@@ -1,70 +1,2 @@
// Host setter for the organization file's `plugins` switch (REQ-PLUG-1,
// REQ-ROLE-3). It is the only writer of that key: no broker verb, mosaic
// command, Settings form or chief-of-staff write reaches it (sage-0301).
// Writes go through the organization writer, which logs old and new names
// like a limit change. A write is a patch per kind (see set()): kinds it doesn't name are kept. A refused write throws a code and logs nothing.
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { isPlainObject } from "./util.mjs";
import { KINDS, PluginError, checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
// `exists(kind, name)`, when given, refuses a name no module answers to (the host passes the
// loader's hasModule). Without it the setter checks only the shape, as before.
export function pluginSettings({ dir, rolesDir, exists = null }) {
const writer = organizationWriter({ dir, rolesDir });
function read(id) {
const { document, revision } = writer.read(id);
return { business: id, revision, plugins: structuredClone(document.plugins ?? {}),
...(document.usage === undefined ? {} : { usage: structuredClone(document.usage) }) };
}
function set(id, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => key !== "revision" && key !== "plugins" && key !== "usage")) {
throw new OrganizationError("invalid-request");
}
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
// A Date or a Map has no own keys and would pass checkPlugins, then serialize
// as something else (Filbert F1, sage-0314). Only a JSON-shaped object passes.
const shaped = args.plugins !== null && typeof args.plugins === "object";
const proto = shaped ? Object.getPrototypeOf(args.plugins) : undefined;
if (proto !== Object.prototype && proto !== null) {
throw new OrganizationError("invalid-plugins", "plugins must be a plain object with one key per kind");
}
// The patch names the kinds it changes (vikunja:19/309): a kind with a value is switched to it,
// a kind with null is removed, and every kind it doesn't name keeps its setting. An empty patch
// changes nothing and is refused rather than written as a no-op.
const patch = Object.entries(args.plugins);
if (patch.length === 0) throw new OrganizationError("invalid-plugins", "the patch names no kind; to remove a kind give it null");
const removals = patch.filter(([, setting]) => setting === null).map(([kind]) => kind);
const sets = Object.fromEntries(patch.filter(([, setting]) => setting !== null));
for (const kind of removals) {
if (!Object.hasOwn(KINDS, kind)) throw new OrganizationError("invalid-plugins", `plugins names unknown kind ${JSON.stringify(kind)}; adding a kind is a core change`);
}
try {
checkPlugins(sets, "plugins");
} catch (e) {
if (e instanceof PluginError) throw new OrganizationError("invalid-plugins", e.message);
throw e;
}
if (exists) {
for (const [kind, setting] of Object.entries(sets)) {
for (const name of Array.isArray(setting) ? setting : [setting]) {
if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`);
}
}
}
const next = structuredClone(writer.read(id).document);
if (Object.hasOwn(args, "usage")) {
if (args.usage === null) delete next.usage;
else next.usage = checkUsageSettings(args.usage);
}
next.plugins = { ...structuredClone(next.plugins ?? {}), ...structuredClone(sets) };
for (const kind of removals) delete next.plugins[kind];
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...read(id), transaction: receipt.transaction };
}
return { writer, read, set };
}
// Old path of packages/organization/src/plugin-settings.mjs (rename design r2, stage B).
export * from "../../organization/src/plugin-settings.mjs";
+2 -79
View File
@@ -1,79 +1,2 @@
// Lifecycle Task 2 (#797 / Gitea #1839): what an exact project addition looks like. The organization
// writer's addProject calls additionProblem on the document it is about to publish, and again on the
// bytes it will write. The bootstrap executor names the paths it reserves with managedPaths, so the
// writer and the executor cannot disagree about the layout.
import { lstatSync, readFileSync } from "node:fs";
import { isAbsolute, join, normalize } from "node:path";
import { canonicalJson } from "./util.mjs";
import { ID_PATTERN } from "./vocabulary.mjs";
export const ADDITION_VERB = "project.add";
const RECEIPT_KEYS = ["data_root", "plan_digest", "project", "request"];
const ENTRY_KEYS = ["remote", "repo", "root"];
const plain = x => x !== null && typeof x === "object" && !Array.isArray(x);
const REQUEST_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/;
// A managed product's code checkout and its records repository, from the data root alone.
export function managedPaths({ dataRoot, business, project }) {
return { root: join(dataRoot, "products", business, project, "code"), repo: join(dataRoot, "projects", business, project) };
}
// null when `next` is `old` plus exactly the planned `projects[project]` entry, else the reason.
export function additionProblem({ old, next, project, entry, receipt }) {
if (typeof project !== "string" || !ID_PATTERN.test(project)) return "project key is not an id";
if (!plain(receipt) || Object.keys(receipt).sort().join() !== RECEIPT_KEYS.join()) return "receipt has the wrong keys";
if (typeof receipt.request !== "string" || receipt.request.length > 160 || !REQUEST_ID.test(receipt.request)) return "receipt request is not an id";
if (typeof receipt.plan_digest !== "string" || !/^[a-f0-9]{64}$/.test(receipt.plan_digest)) return "receipt plan digest is not a digest";
if (receipt.project !== project) return "receipt names another project";
if (typeof receipt.data_root !== "string" || !isAbsolute(receipt.data_root) || normalize(receipt.data_root) !== receipt.data_root) return "receipt data root is not absolute";
if (!plain(entry) || Object.keys(entry).some(k => !ENTRY_KEYS.includes(k))) return "entry holds more than root, repo and remote";
if (typeof entry.root !== "string" || typeof entry.repo !== "string" || ("remote" in entry && typeof entry.remote !== "string")) return "entry root and repo are required strings";
if (!plain(old?.projects) || !plain(next?.projects)) return "business has no project mapping";
const expected = managedPaths({ dataRoot: receipt.data_root, business: old.id, project });
if (entry.root !== expected.root || entry.repo !== expected.repo) return "entry is not the managed product layout";
if (Object.hasOwn(old.projects, project)) return "the business already declares this project";
if (canonicalJson(next.projects) !== canonicalJson({ ...old.projects, [project]: entry })) return "mapping differs from the old mapping plus the entry";
const { projects: _next, ...rest } = next, { projects: _old, ...was } = old;
if (canonicalJson(rest) !== canonicalJson(was)) return "a key outside projects changed";
return null;
}
// The product marker, <dataRoot>/products/<business>/<project>/bootstrap.json. The bootstrap executor writes it
// `reserved` before the mapping exists and `complete` once every step before the activation passed, just before the
// bus admits the project, so a process that cannot read the request journal (the launcher and the boot config) can
// tell a half-built product from a finished one. `complete` alone is not admission: the bus also needs the
// committed activate receipt (#802).
const MARKER_STATES = ["reserved", "complete"];
export const markerFile = ({ dataRoot, business, project }) => join(dataRoot, "products", business, project, "bootstrap.json");
export function readMarker(file) {
let bytes;
try {
if (!lstatSync(file).isFile()) return { state: "invalid" };
bytes = readFileSync(file, "utf8");
} catch (e) {
if (e.code === "ENOENT") return null;
return { state: "invalid" };
}
try {
const m = JSON.parse(bytes);
const keys = Object.keys(m ?? {}).sort().join();
if (plain(m) && keys === "plan_digest,project,request,state,version" && m.version === 1 && MARKER_STATES.includes(m.state) &&
typeof m.project === "string" && typeof m.request === "string" && /^[a-f0-9]{64}$/.test(m.plan_digest)) return m;
} catch { /* falls through */ }
return { state: "invalid" };
}
export const markerBytes = ({ state, request, plan_digest, project }) =>
JSON.stringify({ version: 1, state, request, plan_digest, project }, null, 2) + "\n";
// Whether an entry is a managed product is a fact of its layout, never of the marker: the mapping a bootstrap
// published sits at managedPaths, and the organization writer refuses any other writer that tries to put an entry
// there. So 'unmanaged' only for an entry off that layout; 'complete' only for a readable, matching `complete`
// marker; 'reserved' for everything else at the layout, including a product whose marker is missing or unreadable.
// Neither state means admitted: the bus admits a managed product only on its committed activation receipt, and
// the launcher asks the bus before it clones one.
export function managedState({ dataRoot, business, project, entry }) {
const here = managedPaths({ dataRoot, business, project });
if (!plain(entry) || entry.root !== here.root || entry.repo !== here.repo) return "unmanaged";
const marker = readMarker(markerFile({ dataRoot, business, project }));
return marker?.state === "complete" && marker.project === project ? "complete" : "reserved";
}
// Old path of packages/organization/src/project-bootstrap.mjs (rename design r2, stage B).
export * from "../../organization/src/project-bootstrap.mjs";
+2 -31
View File
@@ -1,31 +1,2 @@
// Narrow, offline validation for a first repository URL. Never include the supplied
// URL in a refusal: it may contain credentials.
import { parseGiteaUrl } from './vocabulary.mjs';
import { canonicalJson } from './util.mjs';
export function remoteTarget(document, project, remote) {
const reject = code => { const error = new Error(code); error.code = code; throw error; };
if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) reject('project-remote-unknown');
const entry = document.projects[project];
if (entry.kind === 'library') reject('project-remote-library');
if (Object.hasOwn(entry, 'remote')) reject('project-remote-configured');
if (!parseGiteaUrl(remote, { repo: true }).url) reject('project-remote-invalid');
}
export function remoteProblem(old, next, project, remote) {
try { remoteTarget(old, project, remote); } catch (error) { return error.code; }
const expected = { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
return canonicalJson(expected) === canonicalJson(next) ? null : 'project-remote-protected';
}
export function getProjectRemote(writer, business, project) {
const { document, revision } = writer.read(business);
if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) {
const error = new Error('project-remote-unknown'); error.code = error.message; throw error;
}
const entry = document.projects[project];
if (entry.kind === 'library') {
const error = new Error('project-remote-library'); error.code = error.message; throw error;
}
return { project, remote: entry.remote ?? null, revision, firstSaveAllowed: !Object.hasOwn(entry, 'remote') };
}
// Old path of packages/organization/src/project-remotes.mjs (rename design r2, stage B).
export * from "../../organization/src/project-remotes.mjs";
+2 -36
View File
@@ -1,36 +1,2 @@
// The project file, <root>/.mosaic/project.json. It lives in the project's
// repository and changes by reviewed commits there. The stack only reads it.
import { isAbsolute, join, normalize } from "node:path";
import { refuse } from "./errors.mjs";
import { checkVars } from "./vars.mjs";
import { requireObject, rejectUnknownKeys, requireId, readJsonFile, deepFreeze } from "./util.mjs";
export function projectFilePath(root) {
return join(root, ".mosaic", "project.json");
}
export function validateProjectDocument(document, file) {
requireObject(document, "project file");
rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file");
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`);
requireId(document.id, "project id");
const vars = checkVars(document.vars ?? {}, "project", `${file} vars`);
const roles = {};
if (document.roles !== undefined) {
requireObject(document.roles, `${file} roles`);
for (const [instance, entry] of Object.entries(document.roles)) {
requireId(instance, `${file} role instance`);
requireObject(entry, `${file} roles.${instance}`);
rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`);
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) };
}
}
return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles });
}
export function loadProject(root) {
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`);
const file = projectFilePath(root);
return validateProjectDocument(readJsonFile(file, "project file"), file);
}
// Old path of packages/organization/src/project.mjs (rename design r2, stage B).
export * from "../../organization/src/project.mjs";
+2 -117
View File
@@ -1,117 +1,2 @@
// The resolver (REQ-VAR-1 and 2). It joins one role instance's definition,
// the business file, the optional project file and the system config into
// the record a launcher or the broker uses. Plain values: the most specific
// layer wins. Limits: every layer narrows, nothing widens.
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
import { checkVars, mergeVars } from "./vars.mjs";
import { canonicalJson, sha256, deepFreeze } from "./util.mjs";
import { projectFilePath } from "./project.mjs";
// The system layer from `mosaic-config.mjs validate` output.
export function systemVars(config) {
return checkVars({
environment: config.environment,
dataRoot: config.dataRoot,
"execution.backend": config.execution.backend,
"execution.provider": config.execution.provider,
"execution.model": config.execution.model,
"execution.adapter": config.execution.adapter,
}, "system", "system config");
}
function narrowerNetwork(a, b) {
return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
}
// resolveInstance({ system, business, project, instance })
// system systemVars(...) output
// business loadBusiness(...) output
// project loadProject(...) output, or null
// instance a role instance the business file declares
export function resolveInstance({ system, business, project = null, instance }) {
const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null;
if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`);
const definition = business.definitions[entry.definition];
const layers = [
{ layer: "system", source: "system", vars: system },
{ layer: "business", source: `business:${business.id}`, vars: business.vars },
];
let projectId = null;
if (project) {
const declared = Object.entries(business.projects).find(([, p]) => p.kind !== 'library' && projectFilePath(p.root) === project.file);
if (!declared || declared[0] !== project.id) {
refuse(`project ${project.id} (${project.file}) isn't declared under that id in business ${business.id}`);
}
projectId = project.id;
for (const name of Object.keys(project.roles)) {
if (!Object.hasOwn(business.roles, name)) refuse(`project ${project.id} sets vars for role instance ${name}, which business ${business.id} doesn't declare`);
}
layers.push({ layer: "project", source: `project:${project.id}`, vars: project.vars });
if (Object.hasOwn(project.roles, instance)) {
layers.push({ layer: "project", source: `project:${project.id}:roles.${instance}`, vars: project.roles[instance].vars });
}
}
layers.push({ layer: "agent", source: `business:${business.id}:roles.${instance}`, vars: entry.vars });
const { vars, provenance } = mergeVars(layers);
let tools = [...definition.tools];
let network = definition.network;
let withinRole = [...definition.authority.withinRole];
let crossRole = [...definition.authority.crossRole];
if (vars["limits.tools"]) tools = tools.filter((t) => vars["limits.tools"].includes(t));
if (vars["limits.network"]) network = narrowerNetwork(network, vars["limits.network"]);
if (vars["limits.authority"]) {
withinRole = withinRole.filter((a) => vars["limits.authority"].includes(a));
crossRole = crossRole.filter((a) => vars["limits.authority"].includes(a));
}
// role.launch needs the business file's launch block naming this
// instance (addendum A section 8), and the verb must survive
// limits.authority. Otherwise the verb is gated and `launch` is null,
// so the two never disagree.
const launch = business.launch && business.launch.by === instance && withinRole.includes("role.launch") ? business.launch : null;
if (!launch) {
withinRole = withinRole.filter((a) => a !== "role.launch");
crossRole = crossRole.filter((a) => a !== "role.launch");
}
const resolved = {
business: business.id,
project: projectId,
instance,
definition: definition.name,
holder: entry.holder,
contract: definition.contractPath,
vars,
provenance,
limits: { tools, network, authority: { withinRole, crossRole } },
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
// so it stays out of the session's credentials and the harness's needs.
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
tracker: entry.tracker,
launch,
// #797 relay R2: an owner-started assistant cannot infer its coordinator
// from launchedBy or running claims. Only the validated business chooses
// this address; it grants no launch authority or messaging exception.
...(definition.name === "assistant" ? { coordinator: business.launch ? {
instance: business.launch.by,
name: business.roles[business.launch.by].identity?.name ?? null,
} : null } : {}),
// REQ-HARN-2: the search-source plug-in the organization file switches on, by name.
webSource: business.plugins?.["search-source"] ?? null,
// The role file's skill names. The bundle resolves each against <dataRoot>/skills-enabled.
skills: [...definition.skills],
};
return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) });
}
// Classify one action for a resolved instance: "within", "cross" or
// "gated". An action outside the vocabulary refuses.
export function classify(resolved, action) {
if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
if (resolved.limits.authority.withinRole.includes(action)) return "within";
if (resolved.limits.authority.crossRole.includes(action)) return "cross";
return "gated";
}
// Old path of packages/organization/src/resolve.mjs (rename design r2, stage B).
export * from "../../organization/src/resolve.mjs";
+2 -232
View File
@@ -1,232 +1,2 @@
// Role definitions, roles/<name>.json. Version 2 (REQ-ROLE-1) adds a
// contract, an authority map over the closed vocabulary and the
// credentials the role needs, and keeps version 1's tool and network
// ceilings. Version 1 files still load: they carry no authority, so every
// vocabulary action is gated for them.
import { existsSync, lstatSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { refuse } from "./errors.mjs";
import {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
CREATIVE_DEFINITIONS, CREATIVE_TOOLS, CREATIVE_ACTIONS, CREATIVE_SERVICE, unhireableAction,
} from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
function checkTools(tools) {
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
return requireDistinctList(tools, "role tools", (tool) => {
if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`);
});
}
function checkNetwork(network) {
if (!NETWORKS.includes(network)) refuse(`role "network" must be one of: ${NETWORKS.join(", ")}`);
return network;
}
function checkAuthority(authority) {
requireObject(authority, 'role "authority"');
rejectUnknownKeys(authority, ["withinRole", "crossRole"], 'role "authority"');
const lists = {};
for (const key of ["withinRole", "crossRole"]) {
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`);
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
});
}
const both = lists.withinRole.filter((a) => lists.crossRole.includes(a));
if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`);
return lists;
}
// REQ-ASSIST-2. Optional: a role with no "messaging" key may message anyone and be messaged by
// anyone the broker's other rules allow. With it, the broker refuses a send to a role outside
// sendTo and a send from a role outside receiveFrom. Entries name role definitions, since a role
// file can't know a business's instance names; "human" names the user. The tester is never a
// role, so it can't be listed.
function checkMessaging(messaging) {
requireObject(messaging, 'role "messaging"');
rejectUnknownKeys(messaging, ["sendTo", "receiveFrom"], 'role "messaging"');
const lists = {};
for (const key of ["sendTo", "receiveFrom"]) {
lists[key] = requireDistinctList(messaging[key], `role messaging.${key}`, (entry) => {
requireId(entry, `role messaging.${key} entry`);
if (entry === "tester") refuse(`messaging.${key} lists tester, which is never a role`);
});
}
return lists;
}
// Optional: the skills a session of this role gets, by their directory name under
// <dataRoot>/skills-enabled. "." and ".." match the pattern but name no skill directory.
function checkSkills(skills) {
return requireDistinctList(skills, "role skills", (name) => {
if (typeof name !== "string" || !SKILL_NAME.test(name) || name === "." || name === "..") {
refuse(`role skill must be a skill directory name matching ${SKILL_NAME} (got ${JSON.stringify(name)})`);
}
});
}
// Row 194 (#1692, D2.3): unhireableAction is vocabulary.mjs's, exported here as before.
export { unhireableAction };
// Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must
// be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file
// is validated without its own "hireable" check, so a list can't recurse. A creative definition
// (row 213, M10) is never hireable: only the human adds one.
function checkHireable(hireable, file) {
const dir = dirname(file);
return requireDistinctList(hireable, "role hireable", (name) => {
requireId(name, "role hireable entry");
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
const path = join(dir, `${name}.json`);
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
const outside = unhireableAction(named);
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
});
}
function checkGiteaScopes(scopes) {
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
}
}, { nonEmpty: true });
const categories = list.map((s) => s.split(":")[1]);
const twice = categories.filter((c, i) => categories.indexOf(c) !== i);
if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`);
return list;
}
function checkVikunjaScopes(scopes) {
requireObject(scopes, "vikunja scopes");
if (Object.keys(scopes).length === 0) refuse("vikunja scopes must name at least one route group");
const out = {};
for (const [group, verbs] of Object.entries(scopes)) {
const allowed = VIKUNJA_GRANTABLE[group];
if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`);
out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => {
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`);
}, { nonEmpty: true });
}
return out;
}
function checkCredentials(credentials) {
if (!Array.isArray(credentials)) refuse('role "credentials" must be an array');
const seen = new Set();
return credentials.map((entry, i) => {
requireObject(entry, `role credentials[${i}]`);
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
const host = entry.service === HOST_SERVICE;
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
if (!host && !SERVICES.includes(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
}
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
seen.add(entry.service);
if (host) return { service: HOST_SERVICE };
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
return { service: entry.service, scopes };
});
}
function checkContract(contract, file) {
if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) {
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`);
}
const path = join(dirname(file), contract);
let stat;
try {
stat = lstatSync(path);
} catch {
refuse(`role contract not found: ${path}`);
}
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) refuse(`role contract must be a non-empty regular file: ${path}`);
return path;
}
// Row 213 (C2-08, Case 2 design 4, M1): a creative role only reads and answers the human. Its file
// may name no tool outside CREATIVE_TOOLS, and must carry a messaging key that names only the human,
// so it can't relay through another role. Row 285 (C2-08b, mos-1309 item 5) lets it write supporting
// docs: its authority is withinRole CREATIVE_ACTIONS at most, and its one credential is gitea.
function checkCreative(role) {
const wider = role.tools.filter((tool) => !CREATIVE_TOOLS.includes(tool));
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools ${wider.join(", ")} (allowed: ${CREATIVE_TOOLS.join(", ")})`);
if (role.authority.crossRole.length > 0 || role.authority.withinRole.some((a) => !CREATIVE_ACTIONS.includes(a))) {
refuse(`creative role ${role.name} may hold only withinRole ${CREATIVE_ACTIONS.join(" and ")}`);
}
const services = role.credentials.map((c) => c.service).filter((service) => service !== CREATIVE_SERVICE);
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential (got ${services.join(", ")})`);
if (role.messaging === null) refuse(`creative role ${role.name} requires "messaging"`);
for (const key of ["sendTo", "receiveFrom"]) {
if (role.messaging[key].some((entry) => entry !== "human")) {
refuse(`creative role ${role.name} messaging.${key} may name only human`);
}
}
return role;
}
// Validate a parsed role document read from `file`. Returns a frozen role:
// { roleVersion, name, title, contract, contractPath, tools, network,
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
// messaging: { sendTo, receiveFrom } | null, skills: [name], hireable: [name] }.
// options.hireable false skips the hireable list's checks of other files (D2.3) and keeps the list.
export function validateRoleDocument(document, file, options = {}) {
requireObject(document, "role");
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
requireId(document.name, "role name");
const base = basename(file).replace(/\.json$/, "");
if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
const tools = checkTools(document.tools);
if (document.roleVersion === 1) {
if (CREATIVE_DEFINITIONS.includes(document.name)) refuse(`creative role ${document.name} must be version 2`);
const network = document.network === undefined ? "none" : checkNetwork(document.network);
return deepFreeze({
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [], hireable: [],
});
}
for (const key of V2_REQUIRED) {
if (document[key] === undefined) refuse(`role version 2 requires "${key}"`);
}
const role = {
roleVersion: 2,
name: document.name,
title: requireString(document.title, 'role "title"', { max: 80 }),
contract: document.contract,
contractPath: checkContract(document.contract, file),
tools,
network: checkNetwork(document.network),
authority: checkAuthority(document.authority),
credentials: checkCredentials(document.credentials),
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
skills: document.skills === undefined ? [] : checkSkills(document.skills),
hireable: document.hireable === undefined ? []
: options.hireable === false ? requireDistinctList(document.hireable, "role hireable", (name) => requireId(name, "role hireable entry"))
: checkHireable(document.hireable, file),
};
return deepFreeze(CREATIVE_DEFINITIONS.includes(role.name) ? checkCreative(role) : role);
}
export function loadRoleFile(file) {
return validateRoleDocument(readJsonFile(file, "role file"), file);
}
export function loadRole(rolesDir, name) {
requireId(name, "role name");
return loadRoleFile(join(rolesDir, `${name}.json`));
}
// Old path of packages/organization/src/role.mjs (rename design r2, stage B).
export * from "../../organization/src/role.mjs";
+2 -54
View File
@@ -1,54 +1,2 @@
// Organization selector options (rename design r2, "Selector scopes and parser matrix").
// scripts/selector.sh is the shell half and keeps the same rules; packages/business/tests/
// selector.test.mjs runs one table against both.
//
// --org and --business are one option family, and --org-file and --business-file are another.
// A scope is one parser's own options: the parser that knows which tokens its other options
// consume calls take() for a selector token it meets, so a value or positional text that happens
// to read "--org" never reaches this file. Within one scope each spelling may appear once, and a
// second spelling must carry exactly the same value. A missing value, an empty one, one that
// starts with "-", an invalid one (by the parser's own predicate) and differing values refuse
// through the parser's own refuse function, so each entry point keeps its exit code and wording.
// The file family compares the strings given; its paths are checked later under existing rules.
export const SELECTOR_FAMILIES = Object.freeze({
id: Object.freeze({ canonical: '--business', spellings: Object.freeze(['--business', '--org']) }),
file: Object.freeze({ canonical: '--business-file', spellings: Object.freeze(['--business-file', '--org-file']) }),
});
// Each refusal names one of these kinds, so a parser can map it to its own usage error.
export const SELECTOR_REFUSALS = Object.freeze(['missing', 'flag', 'repeated', 'invalid', 'differ']);
export function isSelector(token, family = 'id') {
return SELECTOR_FAMILIES[family]?.spellings.includes(token) ?? false;
}
// refuse(kind, message) must throw; take() throws its own error if it returns.
export function selectorScope({ family = 'id', valid = null, refuse }) {
const spec = SELECTOR_FAMILIES[family];
if (!spec) throw new TypeError(`unknown selector family ${family}`);
if (typeof refuse !== 'function') throw new TypeError('selectorScope needs a refuse function');
const seen = [];
let value;
const fail = (kind, message) => {
refuse(kind, message);
throw new Error(`selector refusal returned: ${message}`);
};
return Object.freeze({
family,
canonical: spec.canonical,
owns: token => spec.spellings.includes(token),
take(flag, given) {
if (!spec.spellings.includes(flag)) throw new TypeError(`${flag} is not a ${family} selector`);
if (seen.includes(flag)) fail('repeated', `${flag} given twice`);
if (typeof given !== 'string' || given === '') fail('missing', `${flag} needs one value`);
if (given.startsWith('-')) fail('flag', `${flag} needs one value, not ${given}`);
if (valid && !valid(given)) fail('invalid', `${flag} ${given} is not valid`);
if (seen.length && given !== value) fail('differ', `${seen[0]} ${value} and ${flag} ${given} conflict`);
seen.push(flag);
value = given;
},
get value() { return value; },
get given() { return seen.length > 0; },
});
}
// Old path of packages/organization/src/selector.mjs (rename design r2, stage B).
export * from "../../organization/src/selector.mjs";
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff. Show more