Compare commits
385
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b7fd19d08 | ||
|
|
69f10a4062 | ||
|
|
67eaf6fb47 | ||
|
|
3ea385223e | ||
|
|
193479b52d | ||
|
|
7c580a5625 | ||
|
|
8ebddd6f93 | ||
|
|
127a54fdff | ||
|
|
9a5fbdbda7 | ||
|
|
7345f330fc | ||
|
|
d4696d09eb | ||
|
|
44f257cb06 | ||
|
|
5d27700026 | ||
|
|
69d1bb3aa4 | ||
|
|
9ea7dea711 | ||
|
|
d9a94f51ba | ||
|
|
975084abe2 | ||
|
|
073bbfdb6a | ||
|
|
d1d7b5598d | ||
|
|
ca8135d70c | ||
|
|
bf56583a49 | ||
|
|
c8f433131c | ||
|
|
d1e75f855c | ||
|
|
f710bf1a68 | ||
|
|
2085d75190 | ||
|
|
2f5a8d2cec | ||
|
|
e14ad9ab52 | ||
|
|
9fd16b9739 | ||
|
|
9051ad179b | ||
|
|
2f649ed930 | ||
|
|
db330c12c7 | ||
|
|
0a17d29bce | ||
|
|
3458f6a7ad | ||
|
|
b033952cd5 | ||
|
|
c102980ad4 | ||
|
|
58b96cb715 | ||
|
|
dd3ff944a1 | ||
|
|
8eb81ebec1 | ||
|
|
530597cc84 | ||
|
|
9a0d44f96a | ||
|
|
121b331c6c | ||
|
|
34e06e7de7 | ||
|
|
9bd4f1c405 | ||
|
|
a7b612435b | ||
|
|
87f10772ce | ||
|
|
7db4c5c2ed | ||
|
|
0273a84549 | ||
|
|
3b674b7a66 | ||
|
|
527bc581ca | ||
|
|
1249714a9a | ||
|
|
e175616885 | ||
|
|
6955717612 | ||
|
|
88d9cf750f | ||
|
|
c038706eed | ||
|
|
8622c9d826 | ||
|
|
88eef507b0 | ||
|
|
439bea6915 | ||
|
|
fad8a4718c | ||
|
|
2ff49adff4 | ||
|
|
44c476ebbf | ||
|
|
cde480eb60 | ||
|
|
5808248707 | ||
|
|
afd5827db8 | ||
|
|
d9cc990376 | ||
|
|
83c4e9851e | ||
|
|
22508170a2 | ||
|
|
90a67d050e | ||
|
|
24bdef75fa | ||
|
|
4e2a413640 | ||
|
|
be55549700 | ||
|
|
ddb1554e5b | ||
|
|
b017e66e17 | ||
|
|
172368612c | ||
|
|
1387231e57 | ||
|
|
0292392e64 | ||
|
|
594b8d711c | ||
|
|
3c1ffd2c2d | ||
|
|
4ebb123ba3 | ||
|
|
bb5cecb348 | ||
|
|
88f55d9135 | ||
|
|
e7e1bd26eb | ||
|
|
5d86b8fa93 | ||
|
|
35ea464661 | ||
|
|
e87ecdb3e5 | ||
|
|
a947db7bfd | ||
|
|
a35ea62ab1 | ||
|
|
f6c93dcb5c | ||
|
|
7f0408d417 | ||
|
|
cfd2a19bd7 | ||
|
|
d2a9e26395 | ||
|
|
0734b1f3a5 | ||
|
|
ce6420f3de | ||
|
|
81f58b15c8 | ||
|
|
0c2113f710 | ||
|
|
900a506c1f | ||
|
|
c3d29e796a | ||
|
|
c2365ae519 | ||
|
|
d6302f8e6f | ||
|
|
9aa4983cf2 | ||
|
|
736b0affc1 | ||
|
|
e18d13d36f | ||
|
|
60bc5d2022 | ||
|
|
ea91cfc421 | ||
|
|
acf640d00f | ||
|
|
431ead3a18 | ||
|
|
143ba0f57a | ||
|
|
ee815a72b1 | ||
|
|
94d626dff9 | ||
|
|
ee6c842918 | ||
|
|
ba3b854d50 | ||
|
|
c7a7fd07cc | ||
|
|
5399c6b7e7 | ||
|
|
e09b8783b4 | ||
|
|
abb0c93601 | ||
|
|
e67cced273 | ||
|
|
635cb1f666 | ||
|
|
09d24b9275 | ||
|
|
ed4c543872 | ||
|
|
215faeda0a | ||
|
|
5125fe21b0 | ||
|
|
41e8046371 | ||
|
|
6e16675ea2 | ||
|
|
19ebc422aa | ||
|
|
bd749831b1 | ||
|
|
f8e1b43b5b | ||
|
|
2148c20d26 | ||
|
|
5964dab891 | ||
|
|
bdb903cf69 | ||
|
|
bec2eb118b | ||
|
|
07624140e4 | ||
|
|
1c79af25d4 | ||
|
|
e605c83b27 | ||
|
|
b5ee692843 | ||
|
|
bf8bc2128d | ||
|
|
01904b8f69 | ||
|
|
676900bd46 | ||
|
|
a3b0770205 | ||
|
|
2a30c68b84 | ||
|
|
f8f8f97be7 | ||
|
|
49b7943420 | ||
|
|
19e16bd44f | ||
|
|
3bd490c080 | ||
|
|
4b448109dd | ||
|
|
bc1149c15e | ||
|
|
089953a7cf | ||
|
|
7b25be22e9 | ||
|
|
4e3d179e61 | ||
|
|
ae58482b72 | ||
|
|
b2d40dada0 | ||
|
|
d30a4cce00 | ||
|
|
4cd280e48d | ||
|
|
8738a03893 | ||
|
|
04a01be992 | ||
|
|
812e2df1da | ||
|
|
8c292fb32f | ||
|
|
f45928c311 | ||
|
|
4d24ae8618 | ||
|
|
d790572e2e | ||
|
|
d7b1dd9601 | ||
|
|
0db2d19a22 | ||
|
|
8eb7e6354e | ||
|
|
9014a510a9 | ||
|
|
974e4740ab | ||
|
|
9cd6d39b71 | ||
|
|
143f925fd8 | ||
|
|
24294d3b77 | ||
|
|
24caeab057 | ||
|
|
888a6ad29b | ||
|
|
24462f460e | ||
|
|
a480ee83dc | ||
|
|
fd43ed5420 | ||
|
|
1d84bc3f3d | ||
|
|
6306914965 | ||
|
|
af43a7a63e | ||
|
|
ca97b885b0 | ||
|
|
6db0bead44 | ||
|
|
c671290d77 | ||
|
|
6a9b2cf6c1 | ||
|
|
6bd93a621d | ||
|
|
e9485c3d96 | ||
|
|
d2f0846dcc | ||
|
|
4f22a58041 | ||
|
|
9b6869fab7 | ||
|
|
20ad89c86b | ||
|
|
a55d1a1812 | ||
|
|
9abd7e386f | ||
|
|
9af456c240 | ||
|
|
cb9a0d1642 | ||
|
|
420507da77 | ||
|
|
2508f0aa99 | ||
|
|
d339e8fd21 | ||
|
|
018d96a412 | ||
|
|
b01950e92f | ||
|
|
1bdeed62eb | ||
|
|
840c2b0d96 | ||
|
|
95d5cb32d4 | ||
|
|
d5f3fae896 | ||
|
|
1556982dbc | ||
|
|
1a822493ba | ||
|
|
d2eeb64433 | ||
|
|
5e58597dbe | ||
|
|
fe4fa20309 | ||
|
|
5e93ef70bd | ||
|
|
3884f2de4d | ||
|
|
a3c50d91ca | ||
|
|
2fd102e6af | ||
|
|
efb3c3a10c | ||
|
|
d4d32a80b2 | ||
|
|
c703cc50eb | ||
|
|
3d2b712355 | ||
|
|
245e0c427d | ||
|
|
ff45f7b5d0 | ||
|
|
64350892e7 | ||
|
|
6e9df3c640 | ||
|
|
f5ba042dfa | ||
|
|
7c7dab3898 | ||
|
|
d92de53399 | ||
|
|
d7e303d3c0 | ||
|
|
726d2ad3a2 | ||
|
|
e4ee1acf24 | ||
|
|
5c5a25e4de | ||
|
|
7669321ea2 | ||
|
|
d8e0aec950 | ||
|
|
49d6136b02 | ||
|
|
a80bae950d | ||
|
|
8199261caa | ||
|
|
57a2f2b40e | ||
|
|
93c1de51e1 | ||
|
|
476db12b92 | ||
|
|
5198c3f198 | ||
|
|
19ac0a02d7 | ||
|
|
6d9387c857 | ||
|
|
14cb9c6a1e | ||
|
|
b5b322f80d | ||
|
|
e4674709be | ||
|
|
c56483eb1b | ||
|
|
5c35a250de | ||
|
|
10a1f82031 | ||
|
|
b61789fe26 | ||
|
|
61a907a12f | ||
|
|
6f5b4c3dc1 | ||
|
|
67f5014cc0 | ||
|
|
463745e314 | ||
|
|
03eda02c20 | ||
|
|
3b4055017e | ||
|
|
07373ede4d | ||
|
|
fb5bb98a32 | ||
|
|
47e90767b7 | ||
|
|
00bc602f93 | ||
|
|
d0c223bdf9 | ||
|
|
cc0d24d5c4 | ||
|
|
40fecd4d38 | ||
|
|
7a6fb024b4 | ||
|
|
f82307c4dc | ||
|
|
7102ccb93e | ||
|
|
afdaa6d0e6 | ||
|
|
41749bbd33 | ||
|
|
120af4e193 | ||
|
|
216cd72226 | ||
|
|
6a8ce66702 | ||
|
|
9cd9409089 | ||
|
|
13c70a7a10 | ||
|
|
dd6357e670 | ||
|
|
709a23d08c | ||
|
|
239a2a93f1 | ||
|
|
ea1f058022 | ||
|
|
1fde450ff1 | ||
|
|
c136baa052 | ||
|
|
4f7f6b3281 | ||
|
|
77edb0dea2 | ||
|
|
3676180ae8 | ||
|
|
0e938b66ed | ||
|
|
f0fef26eb7 | ||
|
|
c9bccd4aae | ||
|
|
8ef2e5b91d | ||
|
|
4cab6c09fe | ||
|
|
239fc6d03c | ||
|
|
d085182dc1 | ||
|
|
e949fa3767 | ||
|
|
f1761c91be | ||
|
|
8109f72cf7 | ||
|
|
a0be592d84 | ||
|
|
f4a24b693e | ||
|
|
e4dffb7c18 | ||
|
|
f840843908 | ||
|
|
aacb11b0b9 | ||
|
|
ce6bda18f2 | ||
|
|
aca28405be | ||
|
|
c1eb0659c4 | ||
|
|
b79708fdc7 | ||
|
|
ebe415132e | ||
|
|
ec260e678f | ||
|
|
f16f206a0a | ||
|
|
a186922e3a | ||
|
|
43513c28f7 | ||
|
|
b6c12bdfcb | ||
|
|
b590a5c3d8 | ||
|
|
fb9f9cda5a | ||
|
|
400a21ca18 | ||
|
|
4cefa5cd88 | ||
|
|
ddf8616716 | ||
|
|
30a694358d | ||
|
|
e01dfa0cd7 | ||
|
|
6c4a2eb626 | ||
|
|
540ec5b6ef | ||
|
|
563d1ac053 | ||
|
|
4d8ddb9a0a | ||
|
|
9185b0cce4 | ||
|
|
8925a502ae | ||
|
|
0e4eb1445c | ||
|
|
592d60425f | ||
|
|
a43f343efd | ||
|
|
88ef9d4fa5 | ||
|
|
bda308efd9 | ||
|
|
20718b5a27 | ||
|
|
29db24210c | ||
|
|
a6085eea37 | ||
|
|
e00cc475a2 | ||
|
|
722163671f | ||
|
|
7d84e4ee03 | ||
|
|
4aaf41dd1a | ||
|
|
bf32f29acd | ||
|
|
1655b1579a | ||
|
|
e478a359eb | ||
|
|
76e4242cb1 | ||
|
|
a45f53071a | ||
|
|
00eb216480 | ||
|
|
d46a2d675a | ||
|
|
8c27024d0e | ||
|
|
48bb19310d | ||
|
|
406e40584d | ||
|
|
677aeb0c93 | ||
|
|
caebf9ef70 | ||
|
|
bd0ef2ab25 | ||
|
|
2d5a8c81ec | ||
|
|
884d527cc8 | ||
|
|
b2e005f2b4 | ||
|
|
87daa12976 | ||
|
|
b82a51da80 | ||
|
|
90cf286a09 | ||
|
|
0aef432052 | ||
|
|
41a16cc916 | ||
|
|
e16c08aa9f | ||
|
|
a34e92cf39 | ||
|
|
a4861c221f | ||
|
|
46d68e1ff4 | ||
|
|
c3496334a5 | ||
|
|
6f29d00149 | ||
|
|
068d0f9b1c | ||
|
|
13cd673d50 | ||
|
|
620cc608e0 | ||
|
|
91e692e3e7 | ||
|
|
b4753a75cd | ||
|
|
24bbd40dc7 | ||
|
|
dc67590a96 | ||
|
|
12677a928d | ||
|
|
f158be8003 | ||
|
|
b0f7d26dd9 | ||
|
|
3a1203b2f8 | ||
|
|
df4c591ab4 | ||
|
|
4fa2768962 | ||
|
|
aa0a7b5fa2 | ||
|
|
42ac19af48 | ||
|
|
f744f32214 | ||
|
|
8ff7aac0ca | ||
|
|
80a45b1e1c | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 | ||
|
|
2fa6bcd576 | ||
|
|
1afe2b36dc | ||
|
|
63e77887a8 | ||
|
|
809ca9a1d9 | ||
|
|
57435bb879 | ||
|
|
8c51bf7575 | ||
|
|
c3d2179ad8 | ||
|
|
b47c4024cc | ||
|
|
74d1cdc7c1 | ||
|
|
8cae9e0883 | ||
|
|
2c524b6da2 | ||
|
|
b4f2019529 | ||
|
|
b1eb1fb2f9 | ||
|
|
dfeb4d9692 | ||
|
|
7ea13332ed | ||
|
|
b032d23889 | ||
|
|
ecde74439c |
+20
-150
@@ -1,154 +1,24 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# Non-secret runtime settings for the mosaic-poc-agent container.
|
||||||
# Mosaic — Environment Variables Reference
|
# Copy to .env if you want to override the defaults in compose.yaml.
|
||||||
# Copy this file to .env and fill in the values for your deployment.
|
#
|
||||||
# Lines beginning with # are comments; optional vars are commented out.
|
# NEVER put credentials in this file. Authentication is supplied at
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# runtime only, via one of the two documented paths:
|
||||||
|
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
|
||||||
|
# override the host path with PI_AUTH_FILE)
|
||||||
|
# 2. provider API key environment variable (ZAI_API_KEY or
|
||||||
|
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
|
||||||
|
|
||||||
|
# Model provider (built-in pi provider name)
|
||||||
|
PI_PROVIDER=zai
|
||||||
|
|
||||||
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
|
# Model ID within the provider
|
||||||
# Full connection string used by the gateway, ORM, and migration runner.
|
PI_MODEL=glm-5.3-flash
|
||||||
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
|
|
||||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
|
||||||
|
|
||||||
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
|
# Optional: alternative host path of the pi credential file mounted
|
||||||
# PG_HOST_PORT=5433
|
# read-only at /home/node/.pi/agent/auth.json in the container
|
||||||
|
#PI_AUTH_FILE=/home/jwoltje/.pi/agent/auth.json
|
||||||
|
|
||||||
|
# Optional: documented env-var auth alternative (secret! set in your
|
||||||
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
|
# shell or a gitignored .env, never commit)
|
||||||
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
|
#ZAI_API_KEY=
|
||||||
VALKEY_URL=redis://localhost:6380
|
#ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
# Docker Compose host-port override for the Valkey container (default: 6380)
|
|
||||||
# VALKEY_HOST_PORT=6380
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
|
||||||
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
|
|
||||||
GATEWAY_PORT=14242
|
|
||||||
|
|
||||||
# Comma-separated list of allowed CORS origins.
|
|
||||||
# Must include the web app origin in production.
|
|
||||||
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
|
|
||||||
# REQUIRED — random secret used to sign sessions and tokens.
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
|
||||||
|
|
||||||
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
|
||||||
BETTER_AUTH_URL=http://localhost:14242
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
|
||||||
# Public gateway URL — accessible from the browser, not just the server.
|
|
||||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
|
||||||
|
|
||||||
|
|
||||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
|
||||||
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
|
||||||
|
|
||||||
# Service name shown in traces
|
|
||||||
OTEL_SERVICE_NAME=mosaic-gateway
|
|
||||||
|
|
||||||
|
|
||||||
# ─── AI Providers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Ollama (local models — set OLLAMA_BASE_URL to enable)
|
|
||||||
# OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
|
|
||||||
# OLLAMA_HOST=http://localhost:11434
|
|
||||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
|
||||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
|
||||||
|
|
||||||
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
|
|
||||||
# ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
|
|
||||||
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
|
|
||||||
# OPENAI_API_KEY=sk-...
|
|
||||||
|
|
||||||
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
|
|
||||||
# ZAI_API_KEY=...
|
|
||||||
|
|
||||||
# Custom providers — JSON array of provider configs
|
|
||||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
|
||||||
# MOSAIC_CUSTOM_PROVIDERS=
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Embedding Service ───────────────────────────────────────────────────────
|
|
||||||
# OpenAI-compatible embeddings endpoint (default: OpenAI)
|
|
||||||
# EMBEDDING_API_URL=https://api.openai.com/v1
|
|
||||||
# EMBEDDING_MODEL=text-embedding-3-small
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Log Summarization Service ───────────────────────────────────────────────
|
|
||||||
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
|
|
||||||
# SUMMARIZATION_API_URL=https://api.openai.com/v1
|
|
||||||
# SUMMARIZATION_MODEL=gpt-4o-mini
|
|
||||||
|
|
||||||
# Cron schedule for summarization job (default: every 6 hours)
|
|
||||||
# SUMMARIZATION_CRON=0 */6 * * *
|
|
||||||
|
|
||||||
# Cron schedule for log tier management (default: daily at 03:00)
|
|
||||||
# TIER_MANAGEMENT_CRON=0 3 * * *
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Agent ───────────────────────────────────────────────────────────────────
|
|
||||||
# Filesystem sandbox root for agent file tools (default: process.cwd())
|
|
||||||
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
|
|
||||||
|
|
||||||
# Comma-separated list of tool names available to non-admin users.
|
|
||||||
# Leave unset to allow all tools for all authenticated users.
|
|
||||||
# AGENT_USER_TOOLS=read_file,list_directory,search_files
|
|
||||||
|
|
||||||
# System prompt injected into every agent session (optional)
|
|
||||||
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
|
|
||||||
|
|
||||||
|
|
||||||
# ─── MCP Servers ─────────────────────────────────────────────────────────────
|
|
||||||
# JSON array of MCP server configs — set to enable MCP tool integration.
|
|
||||||
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
|
|
||||||
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Coordinator ─────────────────────────────────────────────────────────────
|
|
||||||
# Root directory used to scope coordinator (worktree/repo) operations.
|
|
||||||
# Defaults to the monorepo root auto-detected from process.cwd().
|
|
||||||
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
|
||||||
# DISCORD_BOT_TOKEN=
|
|
||||||
# DISCORD_GUILD_ID=
|
|
||||||
# DISCORD_GATEWAY_URL=http://localhost:14242
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
|
||||||
# TELEGRAM_BOT_TOKEN=
|
|
||||||
# TELEGRAM_GATEWAY_URL=http://localhost:14242
|
|
||||||
|
|
||||||
|
|
||||||
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
|
||||||
|
|
||||||
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
|
||||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
|
||||||
# AUTHENTIK_CLIENT_ID=
|
|
||||||
# AUTHENTIK_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
|
||||||
# WORKOS_ISSUER=https://your-company.authkit.app
|
|
||||||
# WORKOS_CLIENT_ID=client_...
|
|
||||||
# WORKOS_CLIENT_SECRET=sk_live_...
|
|
||||||
|
|
||||||
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
|
||||||
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
|
||||||
# Legacy alternative if you prefer to compose the issuer from separate vars:
|
|
||||||
# KEYCLOAK_URL=https://auth.example.com
|
|
||||||
# KEYCLOAK_REALM=master
|
|
||||||
# KEYCLOAK_CLIENT_ID=mosaic
|
|
||||||
# KEYCLOAK_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
|
|
||||||
# NEXT_PUBLIC_WORKOS_ENABLED=true
|
|
||||||
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
|
||||||
|
|||||||
+6
-23
@@ -1,25 +1,8 @@
|
|||||||
logs/
|
# build/deps
|
||||||
node_modules
|
node_modules/
|
||||||
dist
|
|
||||||
.turbo
|
# runtime credentials — never commit, never copy into the image
|
||||||
.next
|
|
||||||
coverage
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
secrets/
|
||||||
*.tsbuildinfo
|
|
||||||
.pnpm-store
|
|
||||||
__pycache__/
|
|
||||||
docs/reports/
|
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
# generated runtime state lives in /home/jwoltje/.mosaic-dev (outside this project)
|
||||||
infra/step-ca/dev-password
|
|
||||||
|
|
||||||
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
|
||||||
.mosaic-test-work/
|
|
||||||
|
|
||||||
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
|
|
||||||
# loading it, then unlink. They are untracked but were not ignored, so turbo's
|
|
||||||
# package traversal hashed them and intermittently failed CI with "Package
|
|
||||||
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
|
||||||
# file vanished mid-scan. Ignoring them removes the race.
|
|
||||||
*.timestamp-*.mjs
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
extensions/
|
||||||
|
extensions.installed.sha256
|
||||||
|
.extensions-*
|
||||||
|
state/
|
||||||
|
evidence/
|
||||||
|
native-test-*.log
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Native goal development copy
|
||||||
|
|
||||||
|
From this repository, start a fresh native Pi session:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash scripts/goal-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical source lives under `extensions/`. The launcher first runs `scripts/sync-dev-extensions.sh`, which installs verified ordinary-file copies under `.pi/extensions/`, then loads only the generated goal extension. Global extensions remain unloaded. The launcher keeps your usual native Pi provider authentication; it copies no credentials. Goal state and new conversation files live under `.pi/state/`, which is ignored by Git. Each process gets a fresh incarnation; `/reload` and `/new` in the same process retain its goal. Restarting Pi does not adopt an earlier process's active goal.
|
||||||
|
|
||||||
|
Plain `pi` also discovers `.pi/extensions/goal/index.ts` after project trust, but may load global extensions too. Use the launcher to avoid duplicate `/goal` registrations. This is a local development test, not a sandbox or the managed Mosaic runtime. Docker and `~/.mosaic` are unchanged.
|
||||||
|
|
||||||
|
## Try it
|
||||||
|
|
||||||
|
1. Set `/goal <a long goal with acceptance criteria>`. This starts work immediately.
|
||||||
|
2. Look below the editor for `Goal: Active`. The old above-editor goal widget is gone.
|
||||||
|
3. Run bare `/goal`, then press `Alt+G`. Both show the entire stored goal and its status. Tab remains autocomplete.
|
||||||
|
4. Use `/goal stop` and `/goal resume`. Expect Paused and Active, or Waiting if an untimed wait remains recorded.
|
||||||
|
5. A blocked `goal_report` displays Blocked. A satisfied report displays Complete and retains the full goal for recall without continuing work.
|
||||||
|
6. `/goal clear` removes the retained goal. Try `NO_COLOR=1 bash scripts/goal-dev.sh` to check text-only labels.
|
||||||
|
|
||||||
|
Use terminal scrollback for recall longer than the screen. At narrow widths Pi may truncate its footer status row; bare `/goal` and Alt+G remain available.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --test extensions/goal/test/*.test.ts
|
||||||
|
bash scripts/test-extension-package.sh
|
||||||
|
python3 scripts/test-goal-native.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Contract tests use ordinary read-only fixture copies in `test/fixtures/skills-local/`, not live brain files. The executive-update fixture SHA-256 matches the parser's pinned contract, `bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319`.
|
||||||
|
|
||||||
|
`SOURCE-SNAPSHOT.json` records the original external-source baseline, not the edited candidate. No symlinks are used. Never edit `.pi/extensions/`; the sync script refuses to overwrite installation drift. Make changes under `extensions/`, run the checks, and relaunch. To disable the test, stop its Pi process and remove `.pi/extensions/`. Keep `.pi/state/` only if you need local test state.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"snapshotVersion": 1,
|
||||||
|
"copiedAt": "2026-09-06T04:58:22Z",
|
||||||
|
"source": "~/.mosaic/fleet/extensions",
|
||||||
|
"goalTreeSha256": "8853f2b72dde3e87c4573648b9a931c1c75da87ccde995c3224e6d2e707a75f0",
|
||||||
|
"mosaicCoreLibTreeSha256": "d1194dce31209e5773c6cc5ce571cbca3c39b29d943a79dea06665e05d29f319",
|
||||||
|
"symlinks": false,
|
||||||
|
"autoDiscoveredExtensions": ["goal"],
|
||||||
|
"purpose": "Issue #54 native Pi NG development copy; never loaded by Docker"
|
||||||
|
}
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compatibility entrypoint for the accepted native test command.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
exec scripts/goal-dev.sh "$@"
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
|
|
||||||
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
|
|
||||||
# store. The install step resolves from the baked store (--prefer-offline)
|
|
||||||
# instead of paying a ~731s cold fetch + native compile every run.
|
|
||||||
variables:
|
|
||||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
|
||||||
- &enable_pnpm 'corepack enable'
|
|
||||||
|
|
||||||
when:
|
|
||||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
|
||||||
# push CI is restricted to protected branches (main) so a feature-branch push no
|
|
||||||
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
|
||||||
# CI load on the storage-constrained runner with zero loss of gating (branch
|
|
||||||
# protection requires no push/ci status context; main still gets full push CI).
|
|
||||||
- event: [pull_request, manual]
|
|
||||||
- event: push
|
|
||||||
branch: main
|
|
||||||
|
|
||||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
|
||||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
|
||||||
# This avoids from_secret which is blocked on pull_request events.
|
|
||||||
# If the env vars aren't set, turbo falls back to local cache only.
|
|
||||||
|
|
||||||
steps:
|
|
||||||
install:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- corepack enable
|
|
||||||
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
|
|
||||||
# the baked pnpm store.
|
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
|
||||||
|
|
||||||
# Blocking gate: public framework package must contain no operator-specific
|
|
||||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
|
||||||
sanitization:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache bash
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
|
||||||
# Resident line-count ceiling over framework-owned resident files
|
|
||||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
|
||||||
# Test-membership guard (#1017): also first link of test:framework-shell.
|
|
||||||
# Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
|
|
||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
|
||||||
|
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
|
||||||
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
|
|
||||||
# keep mode is a single cp-based path that must not depend on rsync), and that a
|
|
||||||
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
|
|
||||||
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
|
|
||||||
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
|
|
||||||
# the retained, operator-scoped pre-update backup is taken before any mutation
|
|
||||||
# (0700/0600, secret never logged, retention-pruned) and that the post-sync
|
|
||||||
# verify net restores any operator file a manifest bug lets the sync touch. The
|
|
||||||
# migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
|
|
||||||
# node_modules — runs early alongside sanitization.
|
|
||||||
upgrade-guard:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache bash rsync
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
|
||||||
|
|
||||||
typecheck:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
- pnpm typecheck
|
|
||||||
depends_on:
|
|
||||||
- install
|
|
||||||
- sanitization
|
|
||||||
- upgrade-guard
|
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck
|
|
||||||
lint:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
- pnpm lint
|
|
||||||
depends_on:
|
|
||||||
- typecheck
|
|
||||||
|
|
||||||
format:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
- pnpm format:check
|
|
||||||
depends_on:
|
|
||||||
- typecheck
|
|
||||||
|
|
||||||
test:
|
|
||||||
image: *node_image
|
|
||||||
environment:
|
|
||||||
# Avoid the namespace-level Woodpecker DB service named "postgres".
|
|
||||||
# The Kubernetes backend exposes service containers by step name.
|
|
||||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
# openssl (#912) is the wake HMAC signer: the digest H1/H2, beacon B12,
|
|
||||||
# and install I8 legs hard-require it in CI. It is baked into ci-base via
|
|
||||||
# Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this
|
|
||||||
# `apk add` guarantees openssl is present on PR pipelines too (and is a
|
|
||||||
# fast no-op once the rebuilt image already ships it).
|
|
||||||
- apk add --no-cache openssl
|
|
||||||
# postgresql-client (pg_isready) is baked into ci-base.
|
|
||||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
|
||||||
- |
|
|
||||||
ready=0
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
|
|
||||||
ready=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Waiting for ci-postgres ($i/60)..."
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
if [ "$ready" -ne 1 ]; then
|
|
||||||
echo "ci-postgres did not become ready" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Run migrations (DATABASE_URL is set in environment above)
|
|
||||||
- pnpm --filter @mosaicstack/db run db:migrate
|
|
||||||
# Run all tests
|
|
||||||
- pnpm test
|
|
||||||
depends_on:
|
|
||||||
- typecheck
|
|
||||||
|
|
||||||
services:
|
|
||||||
ci-postgres:
|
|
||||||
image: pgvector/pgvector:pg17
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: mosaic
|
|
||||||
POSTGRES_PASSWORD: mosaic
|
|
||||||
POSTGRES_DB: mosaic
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
|
||||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
|
||||||
|
|
||||||
variables:
|
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
|
||||||
# toolchain + warm pnpm store. Kills the second cold install publish pays.
|
|
||||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
|
||||||
- &enable_pnpm 'corepack enable'
|
|
||||||
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
|
||||||
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
|
||||||
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
|
|
||||||
# (tags) always build everything. Exclude-list keeps the default SAFE: any
|
|
||||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
|
||||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
|
||||||
# the separate `event: tag` entry.)
|
|
||||||
- &image_build_when
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
exclude:
|
|
||||||
- 'packages/mosaic/**'
|
|
||||||
- 'docs/**'
|
|
||||||
- '**/*.md'
|
|
||||||
- '.woodpecker/**'
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: next
|
|
||||||
- &main_image_build_when
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
exclude:
|
|
||||||
- 'packages/mosaic/**'
|
|
||||||
- 'docs/**'
|
|
||||||
- '**/*.md'
|
|
||||||
- '.woodpecker/**'
|
|
||||||
|
|
||||||
when:
|
|
||||||
- branch: [main, next]
|
|
||||||
event: [push, manual, tag]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
install:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- corepack enable
|
|
||||||
# Resolve from the baked pnpm store instead of a cold network fetch.
|
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
|
||||||
|
|
||||||
build:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
- pnpm build
|
|
||||||
depends_on:
|
|
||||||
- install
|
|
||||||
|
|
||||||
publish-npm:
|
|
||||||
image: *node_image
|
|
||||||
# Publish only when a publishable package changed (or on a release tag); a
|
|
||||||
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- 'packages/**'
|
|
||||||
environment:
|
|
||||||
NPM_TOKEN:
|
|
||||||
from_secret: gitea_token
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
# Configure auth for Gitea npm registry
|
|
||||||
- |
|
|
||||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
|
||||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
|
||||||
# Publish non-private packages to Gitea.
|
|
||||||
#
|
|
||||||
# The only publish failure we tolerate is "version already exists" —
|
|
||||||
# that legitimately happens when only some packages were bumped in
|
|
||||||
# the merge. Any other failure (registry 404, auth error, network
|
|
||||||
# error) MUST fail the pipeline loudly: the previous
|
|
||||||
# `|| echo "... continuing"` fallback silently hid a 404 from the
|
|
||||||
# Gitea org rename and caused every @mosaicstack/* publish to fall
|
|
||||||
# on the floor while CI still reported green.
|
|
||||||
- |
|
|
||||||
# Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
|
|
||||||
set +e
|
|
||||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
|
|
||||||
EXIT=$?
|
|
||||||
set -e
|
|
||||||
cat /tmp/publish.log
|
|
||||||
if [ "$EXIT" -eq 0 ]; then
|
|
||||||
echo "[publish] all packages published successfully"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
# Hard registry / auth / network errors → fatal. Match npm's own
|
|
||||||
# error lines specifically to avoid false positives on arbitrary
|
|
||||||
# log text that happens to contain "E404" etc.
|
|
||||||
if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
|
|
||||||
echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Only tolerate the explicit "version already published" case.
|
|
||||||
# npm returns this as E403 with body "You cannot publish over..."
|
|
||||||
# or EPUBLISHCONFLICT depending on version.
|
|
||||||
if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
|
|
||||||
echo "[publish] some packages already at this version — continuing (non-fatal)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "[publish] FATAL: publish failed with unrecognized error — failing pipeline" >&2
|
|
||||||
exit 1
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
|
|
||||||
publish-next-npm:
|
|
||||||
image: *node_image
|
|
||||||
# Durable @next integration-line publish. Runs only on next; never writes
|
|
||||||
# the latest dist-tag and never commits the computed prerelease versions.
|
|
||||||
when:
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: next
|
|
||||||
environment:
|
|
||||||
NPM_TOKEN:
|
|
||||||
from_secret: gitea_token
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
- |
|
|
||||||
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
|
||||||
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
|
||||||
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
|
||||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
|
||||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
|
||||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
|
||||||
const roots = ['apps', 'packages', 'plugins'];
|
|
||||||
const updated = [];
|
|
||||||
|
|
||||||
function walk(dir) {
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const packagePath = path.join(fullPath, 'package.json');
|
|
||||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
|
||||||
walk(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePackage(packagePath) {
|
|
||||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
||||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
|
||||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
|
||||||
if (!stableMatch) {
|
|
||||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
|
||||||
}
|
|
||||||
const [, major, minor, patch] = stableMatch;
|
|
||||||
const oldVersion = manifest.version;
|
|
||||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
|
||||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
|
||||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const root of roots) walk(root);
|
|
||||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
|
||||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
|
||||||
for (const line of updated) console.log('[publish-next] ' + line);
|
|
||||||
NODE
|
|
||||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
|
||||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
|
||||||
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
|
||||||
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
|
||||||
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
|
|
||||||
# TODO: Uncomment when ready to publish to npmjs.org
|
|
||||||
# publish-npmjs:
|
|
||||||
# image: *node_image
|
|
||||||
# environment:
|
|
||||||
# NPM_TOKEN:
|
|
||||||
# from_secret: npmjs_token
|
|
||||||
# commands:
|
|
||||||
# - *enable_pnpm
|
|
||||||
# - apk add --no-cache jq bash
|
|
||||||
# - bash scripts/publish-npmjs.sh
|
|
||||||
# depends_on:
|
|
||||||
# - build
|
|
||||||
# when:
|
|
||||||
# - event: [tag]
|
|
||||||
|
|
||||||
build-gateway:
|
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
|
||||||
REGISTRY_USER:
|
|
||||||
from_secret: gitea_username
|
|
||||||
REGISTRY_PASS:
|
|
||||||
from_secret: gitea_password
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
|
||||||
commands:
|
|
||||||
- mkdir -p /kaniko/.docker
|
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
|
||||||
- |
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish] next gateway publish is sha-only"
|
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
|
||||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
|
||||||
fi
|
|
||||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
|
|
||||||
build-appservice:
|
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
|
||||||
when: *main_image_build_when
|
|
||||||
environment:
|
|
||||||
REGISTRY_USER:
|
|
||||||
from_secret: gitea_username
|
|
||||||
REGISTRY_PASS:
|
|
||||||
from_secret: gitea_password
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
|
||||||
commands:
|
|
||||||
- mkdir -p /kaniko/.docker
|
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
|
||||||
- |
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
|
||||||
fi
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
|
||||||
fi
|
|
||||||
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
|
|
||||||
build-web:
|
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
|
||||||
when: *main_image_build_when
|
|
||||||
environment:
|
|
||||||
REGISTRY_USER:
|
|
||||||
from_secret: gitea_username
|
|
||||||
REGISTRY_PASS:
|
|
||||||
from_secret: gitea_password
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
|
||||||
commands:
|
|
||||||
- mkdir -p /kaniko/.docker
|
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
|
||||||
- |
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
|
||||||
fi
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
|
||||||
fi
|
|
||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
@@ -1,80 +1,124 @@
|
|||||||
# Agent Guidelines — Mosaic Stack
|
# AGENTS.md — Mosaic Stack rebuild (`mosaicstack/stack`, branch `refactor`)
|
||||||
|
|
||||||
## Required Load Order
|
Operational context for any agent session working in this repository.
|
||||||
|
Read top to bottom; it is deliberately short — depth lives in the files it
|
||||||
|
points to, not here.
|
||||||
|
|
||||||
1. `~/.config/mosaic/SOUL.md`
|
## What this repository is
|
||||||
2. `~/.config/mosaic/STANDARDS.md`
|
|
||||||
3. `~/.config/mosaic/AGENTS.md`
|
|
||||||
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
|
|
||||||
5. `AGENTS.md` (this file)
|
|
||||||
6. Runtime-specific guide: `~/.config/mosaic/runtime/<runtime>/RUNTIME.md`
|
|
||||||
|
|
||||||
## Project Context
|
Canonical checkout: `/mnt/storage/src/mosaic-stack`, origin `mosaicstack/stack`,
|
||||||
|
working branch `refactor` (Jason-authorized conversion, issue #1495).
|
||||||
|
The new foundation is at the root. `v1/` is archived legacy source, not the current
|
||||||
|
implementation; its instructions and tools do not govern the new foundation.
|
||||||
|
`~/src/mosaic-stack-dev-test` is a compatibility symlink to this checkout, not a
|
||||||
|
second working tree. Both original Git histories are retained. Conversion receipt:
|
||||||
|
`docs/plans/2026-09-07_repository-consolidation-completed.md`.
|
||||||
|
|
||||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
|
A rebuild of Mosaic Stack: a file-based, fail-closed
|
||||||
|
orchestration foundation that dispatches sandboxed headless pi workers to do
|
||||||
|
real work, with immutable run records as evidence. Thirteen-plus tagged
|
||||||
|
milestones (`git tag -l`) from `poc-container-hello-v0` to today; suites
|
||||||
|
green at every step. Not production software — a proven foundation.
|
||||||
|
|
||||||
## Package Map
|
## Non-negotiable invariants (the canon)
|
||||||
|
|
||||||
| Package | Purpose | Key Dependencies |
|
1. **Root is bootstrap-only.** First-class system configuration lives at the
|
||||||
| ------------------ | ------------------------------- | -------------------------------- |
|
repository root; everything else gets a dedicated directory (`roles/`,
|
||||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
||||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
|
||||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
config — created only by `scripts/bootstrap.sh`, never overwritten,
|
||||||
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
fail-closed on any problem. Repo-scoped role authority lives in
|
||||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
`roles/*.json` (versioned, reviewed commits only).
|
||||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
3. **Secrets** never enter the repository or container images; auth is
|
||||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
runtime-only (read-only mount or environment variable).
|
||||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
|
||||||
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
tasks are declarative JSON with strict schemas.
|
||||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
5. **Run records** under `<dataRoot>/runs/` are write-once evidence — never
|
||||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
rewritten, only pruned via `prune` with a receipt.
|
||||||
|
6. **Fail closed**: missing or invalid config/policy refuses the operation.
|
||||||
|
Never improvise around a refusal; diagnose it.
|
||||||
|
7. **Policy**: missions govern tasks (least-privilege intersection — a task
|
||||||
|
narrows, never widens). Role authority is declared in `roles/` and changes
|
||||||
|
only via reviewed commits.
|
||||||
|
8. **Git**: commit only after applicable suites are green. Work on the
|
||||||
|
owner-authorized `refactor` branch; never force-push. Push remains an explicit
|
||||||
|
act. Do not merge into `next` or `main` without separate authorization.
|
||||||
|
`scripts/conductor-apply.sh` commits locally; it does not authorize a push.
|
||||||
|
9. **Append-only logs**: BUILD-LOG.md (phases), `activation-log.jsonl`,
|
||||||
|
`.pruned.log`, docs/SESSIONS.md. Corrections are new entries, never edits.
|
||||||
|
|
||||||
## Architecture Rules
|
## Session protocol (mandatory)
|
||||||
|
|
||||||
1. Gateway is the single API surface — all clients connect through it
|
- **Register** your session in `docs/SESSIONS.md` — one append-only line
|
||||||
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
(date, actor, scope, outcome). Never rewrite or remove entries.
|
||||||
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
- **Cadence**: read `docs/plans/CURRENT.md` → execute its single next action
|
||||||
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
fully (implement → test → verify against acceptance criteria → commit →
|
||||||
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
push → close issue) → update CURRENT.md → register in SESSIONS.md.
|
||||||
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
- "next" means one action. A batch mandate ("run the queue") repeats the
|
||||||
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
loop until green or blocked. Blocked means stop and report, never improvise.
|
||||||
|
- Substantial work gets a Gitea issue and a BUILD-LOG phase entry
|
||||||
|
(before/after, with corrections recorded honestly).
|
||||||
|
|
||||||
## Development Workflow
|
## Role model
|
||||||
|
|
||||||
```bash
|
- **Conductor**: a system-scoped role — not an agent, not a daemon. Holds
|
||||||
docker compose up -d # Infrastructure
|
git/credentials/policy authority; decomposes, dispatches, reviews,
|
||||||
pnpm install # Dependencies
|
verifies, integrates. Protocol: `docs/plans/CONDUCTOR.md`. Exists only
|
||||||
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
when invoked; push is never automatic.
|
||||||
```
|
- **Workers**: headless pi via `scripts/run-task.sh` — sandboxed workspace,
|
||||||
|
tools allowlist, optional persistent sessions and forks; no git, no
|
||||||
|
credentials, no policy control.
|
||||||
|
- Worker runs deliberately exclude this file (`--no-context-files` in the
|
||||||
|
adapter): worker context is contracts + mission via the generated system
|
||||||
|
prompt. This file is for conductor-level sessions.
|
||||||
|
|
||||||
## Repo-Specific Notes
|
## Command surface
|
||||||
|
|
||||||
- DTOs in `*.dto.ts` files at module boundaries
|
`scripts/bootstrap.sh` (idempotent) · `build.sh` · `hello.sh` ·
|
||||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
`verify.sh` · `run-task.sh run <task.json>` · `release.sh
|
||||||
- NodeNext module resolution in all tsconfigs
|
package|activate|rollback|status` · `auth.sh status|accounts` · `reset.sh` (**danger**: wipes the data
|
||||||
- Scratchpads are mandatory for non-trivial tasks
|
root; triple-safety-checked) · `mosaic-task.mjs validate|run|show|list|retry|prune|resolve-role` ·
|
||||||
|
`agent.sh <name>` (interactive TUI agent) ·
|
||||||
|
suites: `test-config.sh`, `test-task.sh`, `test-release.sh`,
|
||||||
|
`test-conductor.sh`, `test-auth.sh`.
|
||||||
|
|
||||||
## docs/TASKS.md — Schema (CANONICAL)
|
Full reference — usage, fields, exit codes, safety notes:
|
||||||
|
`docs/TOOLS.md` (read on demand; do not rely on this summary for detail).
|
||||||
|
|
||||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
## Data map (canon)
|
||||||
|
|
||||||
| Value | When to use | Budget |
|
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
|
||||||
| --------- | ----------------------------------------------------------- | -------------------------- |
|
auto-written).
|
||||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
||||||
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
||||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
- `sessions/` — pi JSONL session trees, one directory per named session
|
||||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
- `workspaces/` — agent file effects (persistent or `:run` ephemeral)
|
||||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
- `state/` — release pointer + append-only activation/auto-apply logs
|
||||||
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
- Ownership is per-directory; nothing shares state. Directory map and
|
||||||
|
lifecycle rules: README.md "Data map" section.
|
||||||
|
|
||||||
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
## Pointers (depth lives here)
|
||||||
|
|
||||||
**Full schema:**
|
- `docs/plans/CURRENT.md` — THE next action (single source of "what now")
|
||||||
|
- `docs/plans/ROADMAP.md` — agreed milestone path (M16+)
|
||||||
|
- `docs/plans/CONDUCTOR.md` — orchestration protocol and guardrails
|
||||||
|
- `docs/plans/2026-09-02_atomic-mosaic-foundation.md` — architecture, invariants
|
||||||
|
- `docs/plans/2026-09-03_autonomous-run.md` — batch-run tracker
|
||||||
|
- `BUILD-LOG.md` — append-only build/verification history with corrections
|
||||||
|
- `LAYERS.md` — implemented vs deferred layers
|
||||||
|
- `docs/SESSIONS.md` — session registry
|
||||||
|
- `adapters/README.md` — the harness adapter contract
|
||||||
|
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
|
||||||
|
|
||||||
```
|
## Recovery rule
|
||||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
|
||||||
```
|
|
||||||
|
|
||||||
- `status`: `not-started` | `in-progress` | `done` | `failed` | `blocked` | `needs-qa`
|
Compacted, restarted, or new? Nothing that matters is lost: this file +
|
||||||
- `agent`: model value from table above (set before spawning)
|
`docs/plans/CURRENT.md` + `git log --oneline -10` + the suites reconstruct
|
||||||
- `estimate`: token budget e.g. `8K`, `25K`
|
the full state. **Never guess** — verify with the suites; the run records
|
||||||
|
and logs hold the receipts.
|
||||||
|
|
||||||
|
## Version pin
|
||||||
|
|
||||||
|
`@earendil-works/pi-coding-agent` is pinned exactly (see `package.json` /
|
||||||
|
`RELEASE`); never install unversioned. Release identity: `RELEASE` file
|
||||||
|
(0.0.X until declared stable); image tags derive from it.
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# Minimal Mosaic Stack container proof of concept
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Build the smallest isolated container that can:
|
||||||
|
- launch Pi
|
||||||
|
- load a small set of Mosaic-style contract files
|
||||||
|
- send one real request to a model
|
||||||
|
- return a known response.
|
||||||
|
|
||||||
|
This is a standalone experiment. It is not part of the existing Mosaic Stack repository or Software Factory.
|
||||||
|
|
||||||
|
## Working boundary
|
||||||
|
|
||||||
|
The directory containing this brief is the project root.
|
||||||
|
|
||||||
|
### Do not read, copy, mount, import, or modify anything from:
|
||||||
|
- `/home/jwoltje/.mosaic`
|
||||||
|
- `/home/jwoltje/.config/mosaic`
|
||||||
|
- `/home/jwoltje/src/mosaic-stack`
|
||||||
|
- Existing Mosaic Stack worktrees
|
||||||
|
|
||||||
|
### Do not use:
|
||||||
|
- Mosaic orchestration
|
||||||
|
- Mosaic Git wrappers
|
||||||
|
- Fleet agents
|
||||||
|
- Fleet communication
|
||||||
|
- Mosaic role policies
|
||||||
|
- Existing Mosaic contract files
|
||||||
|
- Existing Mosaic runtime state
|
||||||
|
|
||||||
|
No Git credentials, issue, pull request, reviewer, merge, or deployment are required for this experiment.
|
||||||
|
|
||||||
|
Nothing from this experiment may be copied into the existing Mosaic Stack repository until it receives a separate review later.
|
||||||
|
|
||||||
|
## Runtime data
|
||||||
|
|
||||||
|
Use this host directory only for generated runtime data:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/jwoltje/.mosaic-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The source code must remain in the project directory containing this brief.
|
||||||
|
|
||||||
|
Inside the container, use:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/mosaic/contracts Immutable contract files
|
||||||
|
/var/lib/mosaic Generated runtime state
|
||||||
|
/workspace Agent workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount /home/jwoltje/.mosaic-dev at /var/lib/mosaic.
|
||||||
|
|
||||||
|
### Required proof
|
||||||
|
|
||||||
|
The finished experiment must prove one path:
|
||||||
|
|
||||||
|
1. Build one container image.
|
||||||
|
2. Start one Pi agent inside the container.
|
||||||
|
3. Load four local contract files from /opt/mosaic/contracts.
|
||||||
|
4. Send a request that does not contain the expected response.
|
||||||
|
5. Receive MOSAIC_HELLO_OK from the agent.
|
||||||
|
6. Exit successfully when the response matches.
|
||||||
|
7. Exit nonzero when the response does not match.
|
||||||
|
|
||||||
|
This is the entire required functional result.
|
||||||
|
|
||||||
|
### Required discovery
|
||||||
|
|
||||||
|
Before writing the runtime command:
|
||||||
|
|
||||||
|
1. Find the current package documentation for @earendil-works/pi-coding-agent.
|
||||||
|
2. Determine the current package version.
|
||||||
|
3. Determine the supported noninteractive command.
|
||||||
|
4. Determine how Pi accepts a custom system prompt or system prompt file.
|
||||||
|
5. Determine Pi's documented container authentication method.
|
||||||
|
6. Record the commands and findings in BUILD-LOG.md.
|
||||||
|
|
||||||
|
Do not guess CLI flags, authentication paths, or SDK methods.
|
||||||
|
|
||||||
|
Pin the selected Pi package version in the project. Do not install an unversioned package during each container start.
|
||||||
|
|
||||||
|
Prefer the Pi CLI. Use the Pi SDK only if the CLI cannot load the generated system prompt in noninteractive mode.
|
||||||
|
|
||||||
|
### Contract files
|
||||||
|
|
||||||
|
Create these files inside the project:
|
||||||
|
|
||||||
|
```text
|
||||||
|
contracts/CONSTITUTION.md
|
||||||
|
contracts/STANDARDS.md
|
||||||
|
contracts/SOUL.md
|
||||||
|
contracts/USER.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Use these exact contents.
|
||||||
|
|
||||||
|
### contracts/CONSTITUTION.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# POC constitution
|
||||||
|
|
||||||
|
Never print credentials, tokens, or authentication files.
|
||||||
|
|
||||||
|
Follow the loaded system instructions before the user request.
|
||||||
|
```
|
||||||
|
|
||||||
|
### contracts/STANDARDS.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# POC standards
|
||||||
|
|
||||||
|
Answer startup verification requests with only the requested value.
|
||||||
|
Do not add explanation or formatting.
|
||||||
|
```
|
||||||
|
|
||||||
|
### contracts/SOUL.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# POC identity
|
||||||
|
|
||||||
|
Your name is mosaic-poc-agent.
|
||||||
|
|
||||||
|
Your startup marker is MOSAIC_HELLO_OK.
|
||||||
|
|
||||||
|
When asked for your startup marker, return only the marker.
|
||||||
|
```
|
||||||
|
|
||||||
|
### contracts/USER.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# POC user
|
||||||
|
|
||||||
|
This is an isolated local runtime test.
|
||||||
|
```
|
||||||
|
|
||||||
|
Contract loading
|
||||||
|
|
||||||
|
Create a small script that reads the four contract files in this order:
|
||||||
|
|
||||||
|
1. CONSTITUTION.md
|
||||||
|
2. STANDARDS.md
|
||||||
|
3. SOUL.md
|
||||||
|
4. USER.md
|
||||||
|
|
||||||
|
Join them with clear file separators.
|
||||||
|
|
||||||
|
Write the generated system prompt to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/lib/mosaic/system-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass that generated prompt to Pi using its documented CLI or SDK method.
|
||||||
|
|
||||||
|
Do not build:
|
||||||
|
|
||||||
|
- Contract schemas
|
||||||
|
- Contract inheritance
|
||||||
|
- Overlays
|
||||||
|
- Role transitions
|
||||||
|
- Dynamic policy loading
|
||||||
|
- Guide routing
|
||||||
|
- Manifest validation
|
||||||
|
|
||||||
|
Container
|
||||||
|
|
||||||
|
Create one service named:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mosaic-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Use one Containerfile and one compose.yaml.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Use a maintained Node.js base image.
|
||||||
|
- Run as a non-root user.
|
||||||
|
- Install a pinned Pi package version.
|
||||||
|
- Copy the local contract fixtures into /opt/mosaic/contracts.
|
||||||
|
- Do not copy credentials into the image.
|
||||||
|
- Do not mount the Docker socket.
|
||||||
|
- Do not mount either live Mosaic directory.
|
||||||
|
- Do not add a database, web server, queue, or second container.
|
||||||
|
- The container may run as a one-shot command. It does not need to remain running.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Use Pi's documented authentication mechanism.
|
||||||
|
|
||||||
|
Authentication must be supplied at runtime through either:
|
||||||
|
- A read-only mounted credential file
|
||||||
|
- A supported runtime environment variable
|
||||||
|
|
||||||
|
**Never**:
|
||||||
|
- Commit credentials
|
||||||
|
- Copy credentials into the image
|
||||||
|
- Print credentials
|
||||||
|
- Print authentication files
|
||||||
|
- Include credentials in BUILD-LOG.md
|
||||||
|
- Store credentials under the project directory
|
||||||
|
|
||||||
|
Provide .env.example only for non-secret settings such as model or provider names.
|
||||||
|
|
||||||
|
If credentials are unavailable, complete the image and scripts but report that the real model request remains unverified. Do not fake the response.
|
||||||
|
|
||||||
|
### Required commands
|
||||||
|
|
||||||
|
Create these executable scripts:
|
||||||
|
```text
|
||||||
|
scripts/build.sh
|
||||||
|
scripts/hello.sh
|
||||||
|
scripts/verify.sh
|
||||||
|
scripts/reset.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### scripts/build.sh
|
||||||
|
|
||||||
|
Build the container image using Docker Compose.
|
||||||
|
|
||||||
|
### scripts/hello.sh
|
||||||
|
|
||||||
|
Run the mosaic-agent service as a one-shot container.
|
||||||
|
|
||||||
|
Send this exact user request:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Return your startup marker and nothing else.
|
||||||
|
```
|
||||||
|
|
||||||
|
The request must not contain MOSAIC_HELLO_OK.
|
||||||
|
|
||||||
|
Print the model response without printing credentials or unrelated runtime data.
|
||||||
|
|
||||||
|
### scripts/verify.sh
|
||||||
|
|
||||||
|
Run the complete test.
|
||||||
|
|
||||||
|
**It must**:
|
||||||
|
|
||||||
|
1. Build or confirm the image is built.
|
||||||
|
2. Run the agent request.
|
||||||
|
3. Remove surrounding whitespace from the response.
|
||||||
|
4. Compare the response with MOSAIC_HELLO_OK.
|
||||||
|
5. Exit 0 only when they match exactly.
|
||||||
|
6. Exit nonzero with a clear error when they do not match.
|
||||||
|
|
||||||
|
### scripts/reset.sh
|
||||||
|
|
||||||
|
Delete generated POC state only when all checks pass:
|
||||||
|
1. The resolved path is exactly /home/jwoltje/.mosaic-dev.
|
||||||
|
2. The path is not a symbolic link.
|
||||||
|
3. The directory contains a .mosaic-poc-root ownership marker created by this project.
|
||||||
|
|
||||||
|
Refuse to delete anything if a check fails.
|
||||||
|
|
||||||
|
## Required files
|
||||||
|
|
||||||
|
The final project should contain only what the implementation needs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BRIEF.md
|
||||||
|
BUILD-LOG.md
|
||||||
|
README.md
|
||||||
|
LAYERS.md
|
||||||
|
Containerfile
|
||||||
|
compose.yaml
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
.gitignore
|
||||||
|
contracts/
|
||||||
|
scripts/
|
||||||
|
src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove unused files and empty directories.
|
||||||
|
|
||||||
|
Build log
|
||||||
|
|
||||||
|
Create BUILD-LOG.md.
|
||||||
|
|
||||||
|
Treat it as append-only.
|
||||||
|
|
||||||
|
Before each phase, append:
|
||||||
|
- Timestamp
|
||||||
|
- Intended action
|
||||||
|
- Reason
|
||||||
|
- Expected result
|
||||||
|
|
||||||
|
After each phase, append:
|
||||||
|
- Commands run
|
||||||
|
- Observed result
|
||||||
|
- Failure or correction
|
||||||
|
|
||||||
|
Never rewrite an earlier entry. Add a correction as a new entry.
|
||||||
|
|
||||||
|
Do not record credentials.
|
||||||
|
|
||||||
|
Initial decisions:
|
||||||
|
- This is a standalone experiment outside the Mosaic Software Factory.
|
||||||
|
- It does not use existing Mosaic source, tools, contracts, agents, or runtime state.
|
||||||
|
- The first proof uses one Pi agent and four small local contract files.
|
||||||
|
- The only required model result is MOSAIC_HELLO_OK.
|
||||||
|
- Persistence, policy enforcement, Claude, orchestration, and portal work are deferred.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
The experiment passes when:
|
||||||
|
1. scripts/build.sh exits 0.
|
||||||
|
2. The image contains the four local contract files.
|
||||||
|
3. The image contains no credentials.
|
||||||
|
4. The container has no mounts from ~/.mosaic or ~/.config/mosaic.
|
||||||
|
5. scripts/hello.sh performs a real model request.
|
||||||
|
6. The request does not contain the expected marker.
|
||||||
|
7. The agent returns exactly MOSAIC_HELLO_OK.
|
||||||
|
8. scripts/verify.sh exits 0.
|
||||||
|
9. Changing the expected value makes scripts/verify.sh exit nonzero.
|
||||||
|
10. scripts/reset.sh refuses unsafe paths.
|
||||||
|
11. Resetting and rerunning the verification produces the same successful result.
|
||||||
|
|
||||||
|
## Deferred layers
|
||||||
|
|
||||||
|
Document these in LAYERS.md. Do not implement them.
|
||||||
|
|
||||||
|
- L0: Container builds and returns MOSAIC_HELLO_OK.
|
||||||
|
- L1: Persist and resume a named Pi session.
|
||||||
|
- L2: Add a fixed tool permission policy.
|
||||||
|
- L3: Load full versioned contract bundles.
|
||||||
|
- L4: Add Claude as a second runtime.
|
||||||
|
- L5: Add multiple agents and communication.
|
||||||
|
- L6: Add orchestration, knowledge storage, and portal features.
|
||||||
|
|
||||||
|
## Explicit exclusions
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- Existing Mosaic Stack compatibility
|
||||||
|
- Git hosting or CI
|
||||||
|
- Pull requests or code review
|
||||||
|
- Deployment
|
||||||
|
- Persistent agent sessions
|
||||||
|
- Tool read restrictions
|
||||||
|
- Claude
|
||||||
|
- Multiple agents
|
||||||
|
- Fleet communication
|
||||||
|
- Watchers
|
||||||
|
- Role management
|
||||||
|
- Knowledge storage
|
||||||
|
- Database storage
|
||||||
|
- API server
|
||||||
|
- Web interface
|
||||||
|
- Dashboard
|
||||||
|
- Production security architecture
|
||||||
|
|
||||||
|
## Final report
|
||||||
|
|
||||||
|
When finished, report:
|
||||||
|
|
||||||
|
1. Files created.
|
||||||
|
2. Pi package version.
|
||||||
|
3. Exact build command.
|
||||||
|
4. Exact verification command.
|
||||||
|
5. Verification output with credentials removed.
|
||||||
|
6. Whether the real model request passed.
|
||||||
|
7. Any remaining failure.
|
||||||
|
8. Anything implemented beyond this brief.
|
||||||
|
|
||||||
|
Do not describe the experiment as production-ready.
|
||||||
+1832
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1 @@
|
|||||||
# CLAUDE.md — Mosaic Stack
|
@AGENTS.md
|
||||||
|
|
||||||
## Project
|
|
||||||
|
|
||||||
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **API**: NestJS + Fastify adapter (`apps/gateway`)
|
|
||||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
|
||||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
|
||||||
- **Auth**: BetterAuth (`packages/auth`)
|
|
||||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
|
||||||
- **Queue**: Valkey 8 (`packages/queue`)
|
|
||||||
- **Build**: pnpm workspaces + Turborepo
|
|
||||||
- **CI**: Woodpecker CI
|
|
||||||
- **Observability**: OpenTelemetry → Jaeger
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm typecheck # TypeScript check (all packages)
|
|
||||||
pnpm lint # ESLint (all packages)
|
|
||||||
pnpm format:check # Prettier check
|
|
||||||
pnpm test # Vitest (all packages)
|
|
||||||
pnpm build # Build all packages
|
|
||||||
|
|
||||||
# Database
|
|
||||||
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
|
||||||
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
|
||||||
# init SQL, or Compose PostgreSQL service from this checkout.
|
|
||||||
|
|
||||||
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
|
||||||
docker compose up -d valkey
|
|
||||||
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
|
||||||
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
|
||||||
- NodeNext module resolution
|
|
||||||
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
|
|
||||||
- DTOs in `*.dto.ts` files at module boundaries
|
|
||||||
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
|
|
||||||
- All three gates must pass before push: typecheck, lint, format:check
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Minimal Mosaic Stack POC agent image.
|
||||||
|
# Base: maintained Node.js image (same family as Pi's documented
|
||||||
|
# containerization example in docs/containerization.md).
|
||||||
|
FROM node:24-bookworm-slim
|
||||||
|
|
||||||
|
# Tools Pi's documented container image expects (bash, CA certs, git, ripgrep).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Non-root user: the maintained node image ships a 'node' user at
|
||||||
|
# uid/gid 1000, which matches the host user that owns the runtime
|
||||||
|
# state directory mounted at /var/lib/mosaic. It is reused as-is.
|
||||||
|
|
||||||
|
# Pinned Pi install: package.json pins the exact version and
|
||||||
|
# package-lock.json is installed with npm ci. No unversioned installs.
|
||||||
|
WORKDIR /opt/app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
# Immutable contract fixtures (required location), runtime scripts, and
|
||||||
|
# runtime adapters.
|
||||||
|
COPY contracts /opt/mosaic/contracts
|
||||||
|
COPY src /opt/mosaic/src
|
||||||
|
COPY adapters /opt/mosaic/adapters
|
||||||
|
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
|
||||||
|
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
|
||||||
|
&& chmod 0555 /opt/mosaic/adapters /opt/mosaic/adapters/*/adapter.sh
|
||||||
|
|
||||||
|
# Writable state, workspace, and pi agent directory (auth.json is
|
||||||
|
# bind-mounted read-only at runtime; nothing is copied into the image).
|
||||||
|
RUN mkdir -p /var/lib/mosaic /workspace /home/node/.pi/agent \
|
||||||
|
&& chown -R node:node /var/lib/mosaic /workspace /home/node /opt/app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
WORKDIR /workspace
|
||||||
|
ENV HOME=/home/node \
|
||||||
|
PATH="/opt/app/node_modules/.bin:${PATH}" \
|
||||||
|
PI_OFFLINE=1
|
||||||
|
|
||||||
|
# One-shot agent: args form the user request (default is the startup
|
||||||
|
# verification request defined in compose.yaml).
|
||||||
|
ENTRYPOINT ["/opt/mosaic/src/run-agent.sh"]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# LAYERS
|
||||||
|
|
||||||
|
Deferred capability layers for the Mosaic experiment. Only L0 is implemented by
|
||||||
|
this proof of concept; everything below it is documented here and deliberately
|
||||||
|
not implemented (see BRIEF.md, "Explicit exclusions").
|
||||||
|
|
||||||
|
## L0 — Implemented: container returns MOSAIC_HELLO_OK
|
||||||
|
|
||||||
|
One image (`mosaic-poc-agent:0.84.4`, built on `node:24-bookworm-slim`, non-root,
|
||||||
|
pinned Pi) runs one Pi agent one-shot. Four immutable local contract files are
|
||||||
|
loaded in fixed order into the generated system prompt
|
||||||
|
(`/var/lib/mosaic/system-prompt.md`). One real model request is sent
|
||||||
|
noninteractively; the response must equal `MOSAIC_HELLO_OK` exactly or the
|
||||||
|
verification exits nonzero. Authentication is supplied at runtime only
|
||||||
|
(read-only mounted pi auth file, or a provider API key environment variable).
|
||||||
|
|
||||||
|
## L1 — Deferred: persist and resume a named Pi session
|
||||||
|
|
||||||
|
Keep a named Pi session across container runs (`--name`, session storage under
|
||||||
|
`/var/lib/mosaic`), resume it with the documented session flags, and verify
|
||||||
|
state survives a container restart.
|
||||||
|
|
||||||
|
## L2 — Deferred: fixed tool permission policy
|
||||||
|
|
||||||
|
Add a fixed allow/deny policy for Pi tools (e.g. restricting built-in tools via
|
||||||
|
documented `--tools` / `--exclude-tools` or an extension-based permission gate),
|
||||||
|
so contract files can constrain what the agent may do, not just what it says.
|
||||||
|
|
||||||
|
## L3 — Deferred: load full versioned contract bundles
|
||||||
|
|
||||||
|
Replace the four static fixtures with versioned contract bundles: bundle
|
||||||
|
manifests, contract versions, and deterministic ordering/hashing, loaded from
|
||||||
|
an immutable bundle artifact instead of files copied at image build time.
|
||||||
|
|
||||||
|
## L4 — Deferred: Claude as a second runtime
|
||||||
|
|
||||||
|
Add a second runtime (Claude) alongside the Pi agent in the same container
|
||||||
|
stack, behind the same contract-loading path, to compare behavior across
|
||||||
|
runtimes.
|
||||||
|
|
||||||
|
## L5 — Deferred: multiple agents and communication
|
||||||
|
|
||||||
|
Run several named agents with defined roles and a communication channel between
|
||||||
|
them (message passing or shared state under `/var/lib/mosaic`).
|
||||||
|
|
||||||
|
## L6 — Deferred: orchestration, knowledge storage, and portal features
|
||||||
|
|
||||||
|
Fleet-level orchestration, knowledge storage, monitoring, and portal UI on top
|
||||||
|
of L1-L5. This is where the existing Mosaic Stack concepts would be re-evaluated
|
||||||
|
from first principles.
|
||||||
@@ -1,403 +1,238 @@
|
|||||||
# Mosaic Stack
|
# Mosaic Stack — new foundation
|
||||||
|
|
||||||
Self-hosted, multi-user AI agent platform. One config, every runtime, same standards.
|
The active rebuild is at this repository's root. The original Mosaic Stack v1
|
||||||
|
source is archived under `v1/`; it is not the implementation being developed here.
|
||||||
|
|
||||||
Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi — injecting consistent system prompts, guardrails, skills, and mission context into every session. A NestJS gateway provides the API surface, a Next.js dashboard gives you the UI, and a plugin system connects Discord, Telegram, and more.
|
- Canonical checkout: `/mnt/storage/src/mosaic-stack`
|
||||||
|
- Repository: `mosaicstack/stack`
|
||||||
|
- Working branch: `refactor`
|
||||||
|
- Former `~/src/mosaic-stack-dev-test`: compatibility symlink to this same checkout
|
||||||
|
|
||||||
## Quick Install
|
Both original Git histories and pending development work are preserved. See the
|
||||||
|
[conversion record](docs/plans/2026-09-07_repository-consolidation-completed.md)
|
||||||
|
and [current next action](docs/plans/CURRENT.md). Do not use v1's startup commands,
|
||||||
|
package layout or agent instructions for work on the new foundation.
|
||||||
|
|
||||||
```bash
|
## Original container proof
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
|
||||||
|
The foundation began as a standalone container experiment. One container image
|
||||||
|
runs one Pi coding agent with four immutable local contract files as its system
|
||||||
|
prompt, sends exactly one real model request, and was verified to return exactly
|
||||||
|
`MOSAIC_HELLO_OK`. This historical result is not a claim that the full rebuild is
|
||||||
|
production-ready.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
BRIEF.md requirements for the original container proof
|
||||||
|
BUILD-LOG.md append-only build/verification log
|
||||||
|
LAYERS.md implemented layer (L0) and deferred layers (L1-L6)
|
||||||
|
Containerfile image definition (node:24-bookworm-slim, non-root, pinned Pi)
|
||||||
|
compose.yaml one service: mosaic-agent (one-shot; configured via env)
|
||||||
|
package.json pins @earendil-works/pi-coding-agent at exactly 0.84.4
|
||||||
|
package-lock.json resolved lockfile used by npm ci in the image
|
||||||
|
.env.example non-secret settings only (credential-file path, env-var auth)
|
||||||
|
contracts/ CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md (immutable fixtures)
|
||||||
|
scripts/ bootstrap/build/hello/verify/reset + config tooling
|
||||||
|
src/ load-contracts.sh, run-agent.sh (run inside the container)
|
||||||
|
docs/plans/ architecture and milestone plans
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the direct URL:
|
## Configuration
|
||||||
|
|
||||||
```bash
|
The sole discovery entry point is:
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
|
```text
|
||||||
|
~/.config/mosaic-dev/config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
Created only by the explicit, idempotent bootstrap:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL …) --yes # Accept all defaults
|
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
|
||||||
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs both components:
|
Minimal shape (`configVersion` 1):
|
||||||
|
|
||||||
| Component | What | Where |
|
```json
|
||||||
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
|
{
|
||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
"configVersion": 1,
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
"environment": "development",
|
||||||
|
"dataRoot": "/home/jwoltje/.mosaic-dev",
|
||||||
|
"execution": {
|
||||||
|
"backend": "docker",
|
||||||
|
"provider": "zai",
|
||||||
|
"model": "glm-5.3-flash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Install lanes
|
Rules enforced by `scripts/mosaic-config.mjs`:
|
||||||
|
|
||||||
| Lane | Command | Use when | Source |
|
- Unknown keys, unsupported versions/backends, and malformed JSON exit nonzero; nothing is modified.
|
||||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
- `dataRoot` must be absolute, canonical, and must not be or contain the home or configuration directory.
|
||||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
- Validation failures never touch config, state, or images.
|
||||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
- `scripts/test-config.sh` runs the sandboxed config selftests (no Docker required).
|
||||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
|
||||||
|
|
||||||
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it.
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
## Missions & tasks (M2)
|
||||||
|
|
||||||
|
Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later).
|
||||||
|
|
||||||
|
```text
|
||||||
|
missions/hello.json objective + directives (missionVersion 1)
|
||||||
|
tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout
|
||||||
|
<dataRoot>/runs/r-<id>/ immutable run record: task.json, mission.json,
|
||||||
|
stderr.txt, result.json (all write-once)
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic wizard # Full guided setup (gateway install → verify)
|
scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing
|
||||||
|
scripts/run-task.sh run tasks/hello-marker.json # execute; result recorded under dataRoot/runs
|
||||||
|
scripts/mosaic-task.mjs list # list runs and statuses
|
||||||
|
scripts/test-task.sh # selftests (schema negatives + live runs)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Requirements
|
A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history.
|
||||||
|
|
||||||
- Node.js ≥ 22
|
## Release model (M3)
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
|
||||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
`RELEASE` single-sources the release version (0.0.X until declared stable); the image tag derives from it plus the pinned Pi version. Activation is health-gated and every event is recorded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/release.sh package # build + tag the release image
|
||||||
|
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
|
||||||
|
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
|
||||||
|
scripts/release.sh rollback # health-gated return to the previous release
|
||||||
|
scripts/release.sh ensure # self-determination: align installed to RELEASE (safe no-op when aligned)
|
||||||
|
scripts/release.sh status # release, tag, active pointer, recent log
|
||||||
|
scripts/test-release.sh # release selftests
|
||||||
|
```
|
||||||
|
|
||||||
|
`ensure` is invoked automatically by the human-facing launchers (`hello`,
|
||||||
|
`verify`, `agent`): the system determines what is installed and aligns
|
||||||
|
itself — the user never runs release commands manually.
|
||||||
|
|
||||||
|
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
|
||||||
|
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
|
||||||
|
|
||||||
|
A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7.
|
||||||
|
|
||||||
|
## Runtime adapters (M4)
|
||||||
|
|
||||||
|
The harness boundary is formalized: everything upstream (config, contracts, missions, tasks, run records) is harness-agnostic; everything inside an adapter belongs to one runtime.
|
||||||
|
|
||||||
|
```text
|
||||||
|
adapters/<name>/adapter.sh env in: MOSAIC_SYSTEM_PROMPT_FILE, MOSAIC_REQUEST,
|
||||||
|
MOSAIC_PROVIDER, MOSAIC_MODEL
|
||||||
|
stdout: response only; stderr: diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
- Selection: `execution.adapter` in config.json (optional; `pi` default; allowlist `pi`, `mock`)
|
||||||
|
- `pi` — pinned Pi CLI, noninteractive print mode, ambient discovery off
|
||||||
|
- `mock` — deterministic test adapter; never for real verification
|
||||||
|
- Mission directives have a sanctioned injection point: when a task references a mission, the task runner mounts the run snapshot and the generated prompt gains a `MISSION (runtime)` section (objective + directives) after the four immutable contracts
|
||||||
|
- Adding a harness (Claude, Codex, OpenCode) later means adding one directory — no orchestrator changes
|
||||||
|
|
||||||
|
See `adapters/README.md` for the full contract.
|
||||||
|
|
||||||
|
## Workspaces, capabilities, sessions (M5/M6)
|
||||||
|
|
||||||
|
Optional task fields extend what an agent can do — all defaulting to the previous behavior:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workspace": "demo", // ":run" ephemeral, or persistent dataRoot/workspaces/<name>
|
||||||
|
"capabilities": { "tools": ["bash", "read"] }, // pi tool allowlist; absent = no tools
|
||||||
|
"session": "demo" // persistent session at dataRoot/sessions/<name>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- The adapter runs inside the workspace; files it writes are host-visible (`dataRoot/workspaces/<name>`).
|
||||||
|
- Sessions persist via pi's documented `--session-dir`; a follow-up run in the same session resumes the conversation (`-c`) and can recall prior context. Distinct names never share state. Ephemeral (`--no-session`) remains the default when no session is declared.
|
||||||
|
- Selection authority: config for adapter/provider/model; the task file for workspace/capabilities/session.
|
||||||
|
|
||||||
|
Inspect anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/mosaic-task.mjs list # runs with task/workspace/session columns
|
||||||
|
node scripts/mosaic-task.mjs show <runId> # full record + snapshots + artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
Demo fixtures: `tasks/workspace-demo.json`, `tasks/session-demo-1.json` + `tasks/session-demo-2.json`.
|
||||||
|
|
||||||
|
See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan.
|
||||||
|
|
||||||
|
Inside the container:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/mosaic/contracts immutable contract files
|
||||||
|
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
|
||||||
|
/workspace agent workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:<pi>-r<release>`,
|
||||||
|
tag derived from `RELEASE` + the pinned Pi version) with Docker Compose.
|
||||||
|
2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files
|
||||||
|
in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear
|
||||||
|
separators, and writes `/var/lib/mosaic/system-prompt.md`.
|
||||||
|
3. `/opt/mosaic/src/run-agent.sh` starts Pi noninteractively
|
||||||
|
(`pi -p "Return your startup marker and nothing else."`) with
|
||||||
|
`--system-prompt "$(cat /var/lib/mosaic/system-prompt.md)"` and all ambient
|
||||||
|
discovery disabled (`--no-context-files --no-skills --no-extensions
|
||||||
|
--no-prompt-templates --no-themes`), ephemeral (`--no-session`), tool-free
|
||||||
|
(`--no-tools`), and offline for startup network operations (`--offline`).
|
||||||
|
4. `scripts/verify.sh` trims surrounding whitespace from the response and exits 0
|
||||||
|
only when it equals `MOSAIC_HELLO_OK` exactly.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Launching Agent Sessions
|
```bash
|
||||||
|
scripts/bootstrap.sh # create config.json if absent (idempotent)
|
||||||
|
scripts/build.sh # build the image
|
||||||
|
scripts/hello.sh # one-shot request; prints the model response
|
||||||
|
scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK
|
||||||
|
scripts/run-task.sh # run a mission/task file (see Missions & tasks)
|
||||||
|
scripts/release.sh # package / activate / rollback / status (see Release model)
|
||||||
|
scripts/test-config.sh # fast config-layer selftests (no Docker)
|
||||||
|
scripts/test-task.sh # mission/task selftests (schema + adapter seam + live runs)
|
||||||
|
scripts/test-release.sh # release selftests
|
||||||
|
scripts/reset.sh # delete the configured data root (safety-checked)
|
||||||
|
```
|
||||||
|
|
||||||
|
Prove the failure path (acceptance criterion 9):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic pi # Launch Pi with Mosaic injection
|
EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh # must exit nonzero
|
||||||
mosaic claude # Launch Claude Code with Mosaic injection
|
|
||||||
mosaic codex # Launch Codex with Mosaic injection
|
|
||||||
mosaic opencode # Launch OpenCode with Mosaic injection
|
|
||||||
|
|
||||||
mosaic yolo claude # Claude with dangerous-permissions mode
|
|
||||||
mosaic yolo pi # Pi in yolo mode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
|
## Authentication
|
||||||
|
|
||||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
Pi's documented container authentication (see the package's
|
||||||
|
`docs/containerization.md`) is used, in this order:
|
||||||
### TUI & Gateway
|
|
||||||
|
1. **Read-only mounted credential file** (default): the host pi auth file
|
||||||
```bash
|
`~/.pi/agent/auth.json` is bind-mounted read-only to
|
||||||
mosaic tui # Interactive TUI connected to the gateway
|
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
|
||||||
mosaic gateway login # Authenticate with a gateway instance
|
entry for the built-in `zai` provider, so no token refresh writes are needed.
|
||||||
mosaic sessions list # List active agent sessions
|
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
|
||||||
```
|
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
|
||||||
|
passes them through. Pi's documented precedence applies.
|
||||||
### Gateway Management
|
|
||||||
|
Credentials are never committed, never copied into the image, and never printed.
|
||||||
```bash
|
Mosaic-managed named accounts (`agent.sh --auth`) live under the data root
|
||||||
mosaic gateway install # Install and configure the gateway service
|
(`auth/<account>.json`, 0600) — the stack never writes into `~/.pi`.
|
||||||
mosaic gateway verify # Post-install health check
|
`.env.example` contains non-secret settings only.
|
||||||
mosaic gateway login # Authenticate and store a session token
|
|
||||||
mosaic gateway config rotate-token # Rotate your API token
|
## Boundaries honored
|
||||||
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
|
|
||||||
```
|
- No mounts of `~/.mosaic` or `~/.config/mosaic`; no Docker socket mount.
|
||||||
|
- Source stays in this project directory; generated state only in
|
||||||
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
|
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
|
||||||
|
- No database, web server, queue, second container, orchestration, Git
|
||||||
### Configuration
|
integration, persistent sessions, or policy machinery.
|
||||||
|
|
||||||
Mosaic supports three storage tiers: `local` (PGlite, single-host), `standalone` (PostgreSQL, single-host), and `federated` (PostgreSQL + pgvector + Valkey, multi-host). See [Federated Tier Setup](docs/federation/SETUP.md) for multi-user and production deployments, or [Migrating to Federated](docs/guides/migrate-tier.md) to upgrade from existing tiers.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic config show # Print full config as JSON
|
|
||||||
mosaic config get <key> # Read a specific key
|
|
||||||
mosaic config set <key> <val># Write a key
|
|
||||||
mosaic config edit # Open config in $EDITOR
|
|
||||||
mosaic config path # Print config file path
|
|
||||||
```
|
|
||||||
|
|
||||||
### Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic doctor # Health audit — detect drift and missing files
|
|
||||||
mosaic sync # Sync skills from canonical source
|
|
||||||
mosaic skill list # Audit Claude skill registrations and conflicts
|
|
||||||
mosaic skill register <name> # Register one canonical skill with Claude Code
|
|
||||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
|
||||||
mosaic update # Update CLI/framework and auto-register canonical skills
|
|
||||||
mosaic wizard # Full guided setup wizard
|
|
||||||
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
|
||||||
mosaic coord init # Initialize a new orchestration mission
|
|
||||||
mosaic prdy init # Create a PRD via guided session
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sub-package Commands
|
|
||||||
|
|
||||||
Each Mosaic sub-package exposes its API surface through the unified CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User management
|
|
||||||
mosaic auth users list
|
|
||||||
mosaic auth users create
|
|
||||||
mosaic auth sso
|
|
||||||
|
|
||||||
# Agent brain (projects, missions, tasks)
|
|
||||||
mosaic brain projects
|
|
||||||
mosaic brain missions
|
|
||||||
mosaic brain tasks
|
|
||||||
mosaic brain conversations
|
|
||||||
|
|
||||||
# Agent forge pipeline
|
|
||||||
mosaic forge run
|
|
||||||
mosaic forge status
|
|
||||||
mosaic forge resume
|
|
||||||
mosaic forge personas
|
|
||||||
|
|
||||||
# Structured logging
|
|
||||||
mosaic log tail
|
|
||||||
mosaic log search
|
|
||||||
mosaic log export
|
|
||||||
mosaic log level
|
|
||||||
|
|
||||||
# MACP protocol
|
|
||||||
mosaic macp tasks
|
|
||||||
mosaic macp submit
|
|
||||||
mosaic macp gate
|
|
||||||
mosaic macp events
|
|
||||||
|
|
||||||
# Agent memory
|
|
||||||
mosaic memory search
|
|
||||||
mosaic memory stats
|
|
||||||
mosaic memory insights
|
|
||||||
mosaic memory preferences
|
|
||||||
|
|
||||||
# Task queue (Valkey)
|
|
||||||
mosaic queue list
|
|
||||||
mosaic queue stats
|
|
||||||
mosaic queue pause
|
|
||||||
mosaic queue resume
|
|
||||||
mosaic queue jobs
|
|
||||||
mosaic queue drain
|
|
||||||
|
|
||||||
# Object storage
|
|
||||||
mosaic storage status
|
|
||||||
mosaic storage tier
|
|
||||||
mosaic storage export
|
|
||||||
mosaic storage import
|
|
||||||
# Schema migration is unavailable in this release. The current storage wrapper shells
|
|
||||||
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
|
|
||||||
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
|
||||||
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
|
|
||||||
# --run → runner --verify → readiness. Tier copy uses only the separately held secure
|
|
||||||
# migrate-tier route.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telemetry
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local observability (OTEL / Jaeger)
|
|
||||||
mosaic telemetry local status
|
|
||||||
mosaic telemetry local tail
|
|
||||||
mosaic telemetry local jaeger
|
|
||||||
|
|
||||||
# Remote telemetry (dry-run by default)
|
|
||||||
mosaic telemetry status
|
|
||||||
mosaic telemetry opt-in
|
|
||||||
mosaic telemetry opt-out
|
|
||||||
mosaic telemetry test
|
|
||||||
mosaic telemetry upload # Dry-run unless opted in
|
|
||||||
```
|
|
||||||
|
|
||||||
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js ≥ 22
|
|
||||||
- pnpm 10.6+
|
|
||||||
- Docker & Docker Compose
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone [email protected]:mosaicstack/stack.git
|
|
||||||
cd stack
|
|
||||||
|
|
||||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
|
||||||
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
|
||||||
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Verify dependencies and generated state before running source-quality gates.
|
|
||||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
|
||||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
|
||||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
|
||||||
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
|
||||||
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
|
||||||
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
|
||||||
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
|
||||||
# trust anchor outside worktree authority.
|
|
||||||
pnpm preflight
|
|
||||||
|
|
||||||
# Optional local queue service only. This does not start PostgreSQL.
|
|
||||||
docker compose up -d valkey
|
|
||||||
|
|
||||||
# The current Gateway/Web local process is held; see docs/guides/dev-guide.md.
|
|
||||||
# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Held future procedure
|
|
||||||
|
|
||||||
The checked-in Compose PostgreSQL service mounts legacy initialization SQL and is **not** a
|
|
||||||
current PostgreSQL, standalone, or federated developer route. Do not start it with Compose,
|
|
||||||
invoke initialization SQL, or treat the planned migrator as currently executable.
|
|
||||||
|
|
||||||
**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05
|
|
||||||
land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run` →
|
|
||||||
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
|
|
||||||
this README—will provide the reviewed commands and secret-consumer interface.
|
|
||||||
|
|
||||||
For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above
|
|
||||||
starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed,
|
|
||||||
without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route:
|
|
||||||
its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a
|
|
||||||
Gateway start command until KBN-101-02 makes that state fail closed.
|
|
||||||
|
|
||||||
### Quality Gates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm preflight # Checkout/dependency/generated-state validation
|
|
||||||
pnpm typecheck # TypeScript type checking (all packages)
|
|
||||||
pnpm lint # ESLint (all packages)
|
|
||||||
pnpm test # Vitest (all packages)
|
|
||||||
pnpm format:check # Prettier check
|
|
||||||
pnpm format # Prettier auto-fix
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI
|
|
||||||
|
|
||||||
Woodpecker CI runs on every push:
|
|
||||||
|
|
||||||
- `pnpm install --frozen-lockfile`
|
|
||||||
- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path.
|
|
||||||
- `pnpm test` (Turbo-orchestrated across all packages)
|
|
||||||
|
|
||||||
npm packages are published to the Gitea package registry on main merges.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
stack/
|
|
||||||
├── apps/
|
|
||||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
|
||||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
|
||||||
├── packages/
|
|
||||||
│ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
|
|
||||||
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
|
|
||||||
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
|
|
||||||
│ ├── auth/ BetterAuth configuration
|
|
||||||
│ ├── brain/ Data layer (PG-backed)
|
|
||||||
│ ├── queue/ Valkey task queue + MCP
|
|
||||||
│ ├── coord/ Mission coordination
|
|
||||||
│ ├── forge/ Multi-stage AI pipeline (intake → board → plan → code → review)
|
|
||||||
│ ├── macp/ MACP protocol — credential resolution, gate runner, events
|
|
||||||
│ ├── agent/ Agent session management
|
|
||||||
│ ├── memory/ Agent memory layer
|
|
||||||
│ ├── log/ Structured logging
|
|
||||||
│ ├── prdy/ PRD creation and validation
|
|
||||||
│ ├── quality-rails/ Quality templates (TypeScript, Next.js, monorepo)
|
|
||||||
│ └── design-tokens/ Shared design tokens
|
|
||||||
├── plugins/
|
|
||||||
│ ├── discord/ Discord channel plugin (discord.js)
|
|
||||||
│ ├── telegram/ Telegram channel plugin (Telegraf)
|
|
||||||
│ ├── macp/ OpenClaw MACP runtime plugin
|
|
||||||
│ └── mosaic-framework/ OpenClaw framework injection plugin
|
|
||||||
├── tools/
|
|
||||||
│ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
|
|
||||||
├── scripts/agent/ Agent session lifecycle scripts
|
|
||||||
├── docker-compose.yml Dev infrastructure
|
|
||||||
└── .woodpecker/ CI pipeline configs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Design Decisions
|
|
||||||
|
|
||||||
- **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it
|
|
||||||
- **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution
|
|
||||||
- **Socket.IO typed events** — defined in `@mosaicstack/types`, enforced at compile time
|
|
||||||
- **OTEL auto-instrumentation** — loads before NestJS bootstrap
|
|
||||||
- **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata
|
|
||||||
|
|
||||||
### Framework (`~/.config/mosaic/`)
|
|
||||||
|
|
||||||
The framework is the bash-based standards layer installed to every developer machine:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.config/mosaic/
|
|
||||||
├── AGENTS.md ← Central standards (loaded into every runtime)
|
|
||||||
├── SOUL.md ← Agent identity (name, style, guardrails)
|
|
||||||
├── USER.md ← User profile (name, timezone, preferences)
|
|
||||||
├── TOOLS.md ← Machine-level tool reference
|
|
||||||
├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo)
|
|
||||||
├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc.
|
|
||||||
├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/)
|
|
||||||
├── skills/ ← Universal skills (synced from agent-skills repo)
|
|
||||||
├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.)
|
|
||||||
└── memory/ ← Persistent agent memory (preserved across upgrades)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Forge Pipeline
|
|
||||||
|
|
||||||
Forge is a multi-stage AI pipeline for autonomous feature delivery:
|
|
||||||
|
|
||||||
```
|
|
||||||
Intake → Discovery → Board Review → Planning (3 stages) → Coding → Review → Remediation → Test → Deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding), quality gates, and timeouts. The board review uses multiple AI personas (CEO, CTO, CFO, COO + specialists) to evaluate briefs before committing resources.
|
|
||||||
|
|
||||||
## Upgrading
|
|
||||||
|
|
||||||
Run the installer again — it handles upgrades automatically:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use the CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic update # Check + install CLI updates
|
|
||||||
mosaic update --check # Check only, don't install
|
|
||||||
```
|
|
||||||
|
|
||||||
The CLI also performs a background update check on every invocation (cached for 1 hour).
|
|
||||||
|
|
||||||
### Installer Flags
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash tools/install.sh --check # Version check only
|
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
|
||||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
|
||||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
|
||||||
```
|
|
||||||
|
|
||||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a feature branch
|
|
||||||
git checkout -b feat/my-feature
|
|
||||||
|
|
||||||
# Make changes, then verify
|
|
||||||
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check
|
|
||||||
|
|
||||||
# Commit (husky runs lint-staged automatically)
|
|
||||||
git commit -m "feat: description of change"
|
|
||||||
|
|
||||||
# Push and create PR
|
|
||||||
git push -u origin feat/my-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
DTOs go in `*.dto.ts` files at module boundaries. Scratchpads (`docs/scratchpads/`) are mandatory for non-trivial tasks. See `AGENTS.md` for the full standards reference.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Proprietary — all rights reserved.
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Mosaic Stack
|
||||||
|
|
||||||
|
You are the default collaborator for Mosaic Stack: a practical engineering
|
||||||
|
partner helping people build, inspect, and operate a trustworthy foundation
|
||||||
|
for delegated work.
|
||||||
|
|
||||||
|
Mosaic Stack is deliberately small, file-based, and evidence-oriented. Its
|
||||||
|
purpose is not to perform confidence; it is to make useful work attributable,
|
||||||
|
bounded, reproducible, and reviewable. Treat the system's contracts, policies,
|
||||||
|
and run records as part of the product, not paperwork around it.
|
||||||
|
|
||||||
|
Work with calm precision. Start from what the user is trying to accomplish,
|
||||||
|
make the next useful step clear, and explain results in plain language. Be
|
||||||
|
decisive when the evidence supports a decision; be explicit about uncertainty
|
||||||
|
when it does not. Never claim a test, command, integration, or outcome that
|
||||||
|
you have not actually verified.
|
||||||
|
|
||||||
|
Respect boundaries. Ask before expanding scope, changing authority, touching
|
||||||
|
credentials, or taking an irreversible external action. Prefer the least
|
||||||
|
privileged path, preserve user work, and stop on a policy or validation
|
||||||
|
refusal rather than working around it. A clean refusal with a useful diagnosis
|
||||||
|
is better than a superficially successful but untrustworthy result.
|
||||||
|
|
||||||
|
Leave a legible trail. Make changes intentional, keep records honest, and
|
||||||
|
report what changed, how it was checked, and what remains unresolved. When
|
||||||
|
coordinating other workers, give each one a bounded objective and review their
|
||||||
|
evidence instead of treating their confidence as proof.
|
||||||
|
|
||||||
|
The aim is dependable progress: small enough to understand, safe enough to
|
||||||
|
trust, and concrete enough for a person to verify.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Mosaic runtime adapters
|
||||||
|
|
||||||
|
An adapter is the entire harness-specific surface of the system. Everything
|
||||||
|
upstream of an adapter — configuration, contracts, missions, tasks, run
|
||||||
|
records — is harness-agnostic; everything inside an adapter may assume one
|
||||||
|
specific agent runtime.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
An adapter lives at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/mosaic/adapters/<name>/adapter.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
and must be executable. The dispatcher (`/opt/mosaic/src/run-agent.sh`)
|
||||||
|
selects it via `MOSAIC_ADAPTER` (default: `pi`) and execs it after the
|
||||||
|
system prompt has been generated.
|
||||||
|
|
||||||
|
**Inputs (environment):**
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `MOSAIC_SYSTEM_PROMPT_FILE` | Absolute path to the generated system prompt (contracts + optional mission section). Read it; do not modify it. |
|
||||||
|
| `MOSAIC_REQUEST` | The exact user request text (may contain newlines). |
|
||||||
|
| `MOSAIC_PROVIDER` | Configured provider name. |
|
||||||
|
| `MOSAIC_MODEL` | Configured model id. |
|
||||||
|
|
||||||
|
Optional, adapter-specific (documented per adapter):
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `MOSAIC_MOCK_RESPONSE` | mock only: the verbatim response to emit |
|
||||||
|
|
||||||
|
**Outputs:**
|
||||||
|
|
||||||
|
- `stdout`: the model response text — the only channel the orchestrator captures
|
||||||
|
- `stderr`: diagnostics (never credentials)
|
||||||
|
- exit `0`: success; nonzero: failure
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Adapters print ONLY the response on stdout. Status lines go to stderr.
|
||||||
|
2. Adapters never read configuration files; the resolved settings arrive via environment.
|
||||||
|
3. Adapters never write outside `/var/lib/mosaic`.
|
||||||
|
4. Adding an adapter requires: a new directory, the contract implementation, and
|
||||||
|
adding the name to the allowlist in `scripts/mosaic-config.mjs`.
|
||||||
|
|
||||||
|
## Included adapters
|
||||||
|
|
||||||
|
- `pi` — the pinned `@earendil-works/pi-coding-agent` CLI in noninteractive
|
||||||
|
print mode (`-p`), ambient discovery disabled, stdin detached.
|
||||||
|
- `mock` — deterministic echo of `MOSAIC_MOCK_RESPONSE`. Test-only: never use
|
||||||
|
it where a real model response is required.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Mock adapter: deterministic response for seam tests. NEVER use where a
|
||||||
|
# real model response is required.
|
||||||
|
#
|
||||||
|
# Contract: see /opt/mosaic/adapters/README.md.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
|
||||||
|
if [ "${MOSAIC_INTERACTIVE:-}" != "1" ]; then
|
||||||
|
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||||
|
fi
|
||||||
|
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
|
||||||
|
|
||||||
|
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
|
||||||
|
# Deterministic plumbing evidence: which MOSAIC_* variables did the
|
||||||
|
# orchestrator actually deliver? (Auth secrets are not MOSAIC_-prefixed.)
|
||||||
|
(env | grep '^MOSAIC_' | sort) >&2 2>/dev/null || true
|
||||||
|
printf '%s\n' "${MOSAIC_MOCK_RESPONSE:-}"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Pi adapter: implements the Mosaic adapter contract for the pinned
|
||||||
|
# @earendil-works/pi-coding-agent CLI.
|
||||||
|
#
|
||||||
|
# Contract: see /opt/mosaic/adapters/README.md.
|
||||||
|
# Headless (default): stdout = response only; stderr = diagnostics; exit 0.
|
||||||
|
# Interactive (MOSAIC_INTERACTIVE=1): full pi TUI on the attached terminal.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
|
||||||
|
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
|
||||||
|
# MOSAIC_AGENT_NAME is optional in headless mode (identity section is then
|
||||||
|
# omitted); interactive launches always set it via scripts/agent.sh.
|
||||||
|
|
||||||
|
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
|
||||||
|
: "${PI_MODEL:?pi adapter: PI_MODEL is required}"
|
||||||
|
|
||||||
|
INTERACTIVE="${MOSAIC_INTERACTIVE:-}"
|
||||||
|
if [ "$INTERACTIVE" != "1" ]; then
|
||||||
|
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Workspace (M5): run inside the provided workspace when present.
|
||||||
|
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
|
||||||
|
mkdir -p "$MOSAIC_WORKSPACE"
|
||||||
|
cd "$MOSAIC_WORKSPACE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Session (M6/M11): default ephemeral (--no-session). With a declared
|
||||||
|
# session dir: persist there and resume the most recent session. With a
|
||||||
|
# fork source: branch the source session file into the target dir
|
||||||
|
# (pi --fork) - the ancestor session is never modified.
|
||||||
|
SESSION_FLAGS="--no-session"
|
||||||
|
if [ -n "${MOSAIC_SESSION_FORK:-}" ]; then
|
||||||
|
[ -n "${MOSAIC_SESSION_DIR:-}" ] || { echo "pi adapter: session fork requires MOSAIC_SESSION_DIR" >&2; exit 2; }
|
||||||
|
mkdir -p "$MOSAIC_SESSION_DIR"
|
||||||
|
SESSION_FLAGS="--fork $MOSAIC_SESSION_FORK --session-dir $MOSAIC_SESSION_DIR"
|
||||||
|
elif [ -n "${MOSAIC_SESSION_DIR:-}" ]; then
|
||||||
|
mkdir -p "$MOSAIC_SESSION_DIR"
|
||||||
|
SESSION_FLAGS="--session-dir $MOSAIC_SESSION_DIR"
|
||||||
|
if [ -n "$(ls -A "$MOSAIC_SESSION_DIR" 2>/dev/null)" ]; then
|
||||||
|
SESSION_FLAGS="$SESSION_FLAGS -c"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Capabilities (M5): explicit allowlist or no tools.
|
||||||
|
TOOLS_FLAG="--no-tools"
|
||||||
|
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
|
||||||
|
|
||||||
|
# Skills (M17): explicitly provided skill dirs replace discovery. When none
|
||||||
|
# are provided the agent runs with --no-skills (nothing ambient to find).
|
||||||
|
SKILLS_FLAG="--no-skills"
|
||||||
|
if [ -n "${MOSAIC_SKILLS:-}" ]; then
|
||||||
|
SKILLS_FLAG=""
|
||||||
|
OLDIFS=$IFS; IFS=','
|
||||||
|
for s in $MOSAIC_SKILLS; do
|
||||||
|
[ -d "$s" ] || { echo "pi adapter: skill dir missing: $s" >&2; exit 2; }
|
||||||
|
SKILLS_FLAG="$SKILLS_FLAG --skill $s"
|
||||||
|
done
|
||||||
|
IFS=$OLDIFS
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mode (M13): interactive TUI or one-shot print.
|
||||||
|
PRINT_MODE="-p"
|
||||||
|
REQUEST_ARG=""
|
||||||
|
if [ "$INTERACTIVE" = "1" ]; then
|
||||||
|
PRINT_MODE=""
|
||||||
|
else
|
||||||
|
REQUEST_ARG="$MOSAIC_REQUEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# All flags documented in the pi package README (CLI Reference):
|
||||||
|
# -p/--print one-shot mode: print the response and exit (omitted in
|
||||||
|
# interactive TUI mode)
|
||||||
|
# --system-prompt replace the default prompt with the generated one
|
||||||
|
# --no-* no ambient context/skills/extensions/templates/themes
|
||||||
|
# SESSION_FLAGS ephemeral | persistent | forked (per env)
|
||||||
|
# TOOLS_FLAG per capabilities
|
||||||
|
# --offline no startup network operations (update checks/telemetry)
|
||||||
|
PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
|
||||||
|
set -- \
|
||||||
|
--offline \
|
||||||
|
--no-extensions \
|
||||||
|
$SKILLS_FLAG \
|
||||||
|
--no-prompt-templates \
|
||||||
|
--no-themes \
|
||||||
|
--no-context-files \
|
||||||
|
$TOOLS_FLAG \
|
||||||
|
$SESSION_FLAGS \
|
||||||
|
--provider "$PI_PROVIDER" \
|
||||||
|
--model "$PI_MODEL" \
|
||||||
|
--system-prompt "$PROMPT_CONTENT"
|
||||||
|
# One-shot mode appends -p and the request (both safely quoted);
|
||||||
|
# interactive mode appends nothing - clean TUI.
|
||||||
|
[ "$INTERACTIVE" = "1" ] || set -- "$@" -p "$MOSAIC_REQUEST"
|
||||||
|
exec pi "$@"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
===== DARKWING NATIVE DEVELOPMENT CONTEXT =====
|
||||||
|
|
||||||
|
Your identity is Darkwing. This launch runs Pi directly on the host, in the
|
||||||
|
Mosaic Stack development repository. The injected SOUL defines your persona;
|
||||||
|
CONSTITUTION and STANDARDS supply governance, USER supplies user context,
|
||||||
|
and AGENTS.md supplies repository instructions.
|
||||||
|
|
||||||
|
You have host read, bash, edit, write, grep, find, and ls tools. This is a
|
||||||
|
development TUI with the operator's OS access, not a sandbox or a registered
|
||||||
|
managed fleet seat. Use repository scripts for Mosaic operations and inspect
|
||||||
|
their effects before running them. Container paths in skills describe worker
|
||||||
|
deployments, not your current workspace. A tool's presence is not authority
|
||||||
|
to change unrelated files, other agents' work, or the live fleet.
|
||||||
|
|
||||||
|
For an assigned improvement, inspect the implementation, reproduce the issue,
|
||||||
|
make the smallest useful change, verify it, and continue through the authorized
|
||||||
|
outcome. Read docs/plans/CURRENT.md to reconcile ownership and existing gates;
|
||||||
|
a new user assignment does not silently resume unrelated queued work.
|
||||||
|
|
||||||
|
The local /goal extension is loaded and owns any operator-set goal lifecycle.
|
||||||
|
Use ms-proactive-agent for work selection and ms-goal for recovery guidance;
|
||||||
|
do not create a competing goal loop. Follow goal_report's actual schema and
|
||||||
|
reporting instructions. Its text format is Just Completed / Next Step /
|
||||||
|
Blocked, with '* none' for empty sections. No external reporting skill is
|
||||||
|
needed to discover that format. Native development packaging supersedes
|
||||||
|
older skill statements that this extension is unavailable.
|
||||||
|
|
||||||
|
For relocation recovery, read agents/darkwing/work/RESTART.md after the root
|
||||||
|
AGENTS.md and docs/plans/CURRENT.md. It records verified checkpoints and limits,
|
||||||
|
not a new assignment. The canonical checkout is /mnt/storage/src/mosaic-stack;
|
||||||
|
v1/ is archived legacy source. Reconcile newer owner direction before acting.
|
||||||
|
|
||||||
|
Conversation history persists across launcher restarts. Goals belong to a
|
||||||
|
single process incarnation; recover the assignment from verified records and
|
||||||
|
the operator's direction after a restart. No goal is started by this launcher.
|
||||||
|
Context is captured anew at launch; source edits do not update this process's
|
||||||
|
injected snapshot. Relaunch to load approved context changes.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Darkwing development TUI
|
||||||
|
|
||||||
|
From any terminal, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/home/jwoltje/src/mosaic-stack-dev-test/agents/darkwing/launch.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent launcher is a thin shim to `scripts/agent.sh --host-dev darkwing`,
|
||||||
|
forwarding all arguments unchanged. `scripts/agent.sh` is the common entry
|
||||||
|
point; `scripts/agent-host-dev.sh` implements its native development mode.
|
||||||
|
The host launcher opens the repository as Darkwing's workspace.
|
||||||
|
It uses the repository-pinned Pi, the configured Mosaic provider/model, and
|
||||||
|
native Pi authentication (normal `~/.pi/agent`, or `PI_CODING_AGENT_DIR` if
|
||||||
|
explicitly set). It never copies credentials. Install dependencies with
|
||||||
|
`npm ci --ignore-scripts --no-audit --no-fund` if needed.
|
||||||
|
|
||||||
|
`--check` validates configuration and required inputs without opening Pi or
|
||||||
|
calling a model. `--fresh` starts a new conversation without deleting earlier
|
||||||
|
ones. Normal launches continue the latest conversation under
|
||||||
|
`.pi/state/darkwing/sessions/`; the first launch creates one. A launcher lock
|
||||||
|
rejects simultaneous launches through this script. It does not exclude Pi
|
||||||
|
processes started another way. Damaged JSONL history refuses automatic resume;
|
||||||
|
`--fresh` is an explicit escape hatch that preserves the damaged evidence.
|
||||||
|
|
||||||
|
The current files are combined into a private launch snapshot under
|
||||||
|
`.pi/state/darkwing/launches/`:
|
||||||
|
|
||||||
|
- `contracts/CONSTITUTION.md` and `contracts/STANDARDS.md`
|
||||||
|
- `agents/darkwing/SOUL.md`
|
||||||
|
- `<configured dataRoot>/user/USER.md`, the deployment's live user profile
|
||||||
|
- the repository's `AGENTS.md` and Darkwing's `CONTEXT.md`
|
||||||
|
|
||||||
|
Use `--soul FILE`, `--constitution FILE`, or `--user FILE` to select alternate
|
||||||
|
inputs, including a future `contracts/USER.md`. Relative paths resolve from
|
||||||
|
the repository root. Missing or empty inputs refuse launch. Snapshots can
|
||||||
|
contain personal context and remain local, with private file permissions.
|
||||||
|
Context edits take effect on relaunch, including when resuming a conversation.
|
||||||
|
|
||||||
|
The launcher enables coding/search tools, `goal_report`, ten explicit local
|
||||||
|
skills, and the canonical goal extension through `scripts/sync-dev-extensions.sh`.
|
||||||
|
Ambient context, skills, extensions, templates, and themes are disabled.
|
||||||
|
The normal Pi coding prompt is retained with the Mosaic context appended.
|
||||||
|
Enter `/goal <assignment and acceptance criteria>` to start continuing work;
|
||||||
|
`/goal stop`, `/goal resume`, and `/goal` pause, resume, and inspect it. A new
|
||||||
|
process does not automatically adopt a previous process's goal.
|
||||||
|
|
||||||
|
This TUI has the operator's host access, including repository edits and host
|
||||||
|
commands. Its tool list is not OS isolation. It creates no managed role or
|
||||||
|
fleet registration. Worker dispatch still uses the governed Mosaic task runner.
|
||||||
|
The user supplies the assignment; launch alone does not start self-modification.
|
||||||
|
|
||||||
|
## Deployment findings
|
||||||
|
|
||||||
|
The existing `scripts/agent.sh` launches a Docker container, defaults to the
|
||||||
|
`agent-<name>` session directory, and asks Pi to continue when that directory
|
||||||
|
is nonempty. Its default workspace is `<dataRoot>/workspaces/<name>`, not this
|
||||||
|
checkout. `src/load-contracts.sh` loads image-baked governance, an optional
|
||||||
|
seat SOUL override, live user Markdown, and mission context into a shared
|
||||||
|
prompt path. A seat override requires `agent.json`; a standalone SOUL is not
|
||||||
|
discovered. `adapters/pi/adapter.sh` disables extensions. The temporary host
|
||||||
|
launcher follows the existing native development path to provide repository
|
||||||
|
access and `/goal`, and keeps its conversations separate from container and
|
||||||
|
live fleet sessions. It does not invoke release alignment on startup.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# SOUL — Darkwing
|
||||||
|
|
||||||
|
You are Darkwing, Mosaic Stack's hands-on engineering collaborator. Your job
|
||||||
|
is to help Jason make the system dependable by using it, finding where it
|
||||||
|
falls short, and carrying authorized improvements through verification.
|
||||||
|
|
||||||
|
Be curious, direct, and resourceful. Have a technical opinion and explain
|
||||||
|
the evidence behind it. Investigate before guessing. Distinguish a design
|
||||||
|
claim, a passing test, and behavior you have observed in the running system.
|
||||||
|
|
||||||
|
Use Mosaic's own tools and workflows where they fit. Turn a failure into a
|
||||||
|
reproducible case, make a focused correction, and test the behavior again.
|
||||||
|
Let each verified improvement inform the next one within the assignment.
|
||||||
|
Keep the human informed when the result, scope, or next decision changes.
|
||||||
|
|
||||||
|
Own the outcome while respecting other agents' work. Preserve their changes
|
||||||
|
and records, give delegated work clear boundaries, and seek independent
|
||||||
|
review where required. Self-improvement never grants new authority: changing
|
||||||
|
your instructions, permissions, or a live deployment follows the same review
|
||||||
|
and authorization rules as any other system change.
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Darkwing's native development mode through the Mosaic agent entry point.
|
||||||
|
set -euo pipefail
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
exec "$REPO/scripts/agent.sh" --host-dev darkwing "$@"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Refuse damaged history before Pi's --continue can silently skip it.
|
||||||
|
import { readFileSync, lstatSync } from 'node:fs';
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const file of process.argv.slice(2)) {
|
||||||
|
if (!lstatSync(file).isFile()) throw new Error(`not a regular session file: ${file}`);
|
||||||
|
const lines = readFileSync(file, 'utf8').trim().split('\n');
|
||||||
|
const entries = lines.map((line) => JSON.parse(line));
|
||||||
|
const header = entries[0];
|
||||||
|
if (header?.type !== 'session' || typeof header.id !== 'string' || !header.id ||
|
||||||
|
typeof header.version !== 'number' || typeof header.cwd !== 'string' ||
|
||||||
|
!Number.isFinite(Date.parse(header.timestamp)) ||
|
||||||
|
entries.slice(1).some((entry) => !entry || typeof entry.type !== 'string')) {
|
||||||
|
throw new Error(`invalid session structure: ${file}`);
|
||||||
|
}
|
||||||
|
if (header.cwd !== process.cwd()) throw new Error(`session belongs to another workspace: ${file}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`darkwing: cannot safely resume: ${error.message}; inspect history or explicitly use --fresh`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Darkwing — relocation handoff
|
||||||
|
|
||||||
|
Recorded 2026-09-07 17:43 UTC. Jason intends to relaunch with
|
||||||
|
`/mnt/storage/src/mosaic-stack/agents/darkwing/launch.sh`.
|
||||||
|
This is a recovery note, not a new assignment or automatic goal resumption.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
1. Root `AGENTS.md` and `docs/plans/CURRENT.md`.
|
||||||
|
2. This note, then `git status --short` and `git log --oneline -5`.
|
||||||
|
3. Reconcile current owner direction and any newer declared artifacts before acting.
|
||||||
|
|
||||||
|
## Repository conversion is completed locally
|
||||||
|
|
||||||
|
Jason explicitly ordered the conversion and confirmed no work was active.
|
||||||
|
- Canonical checkout: `/mnt/storage/src/mosaic-stack`.
|
||||||
|
- Origin: `https://git.mosaicstack.dev/mosaicstack/stack`.
|
||||||
|
- Branch: `refactor`.
|
||||||
|
- Conversion commit: `127a54fdff1fe6ae56c3197edddf957481465db4`.
|
||||||
|
- New foundation is at root. `v1/` is legacy archival source, NOT current code.
|
||||||
|
- Old `/home/jwoltje/src/mosaic-stack-dev-test` is a compatibility symlink to this
|
||||||
|
same checkout. Do not recreate a second working copy there.
|
||||||
|
- Both histories retained: merge parents v2 `9a5fbdbda74b16adf488fe28138b2ba69ea5e669`
|
||||||
|
and v1 `5d2770002612a09ae0cadc129b4ea30619133e8a`.
|
||||||
|
- Exact 3,507-file v1 tracked tree imported; v1 refs under `refs/archive/v1/`.
|
||||||
|
- Original v2 refs retained; `stack-v2-archive` remote has a disabled push URL.
|
||||||
|
- Only legacy tracked tree and four conversion docs committed. All earlier
|
||||||
|
uncommitted/untracked/ignored work preserved. Index was verified clean.
|
||||||
|
- Issue https://git.mosaicstack.dev/mosaicstack/stack/issues/1495 closed explicitly
|
||||||
|
for local conversion. No push, PR/trunk merge or live-service change occurred.
|
||||||
|
|
||||||
|
Record: `docs/plans/2026-09-07_repository-consolidation-completed.md`.
|
||||||
|
Receipts: `docs/plans/reviews/2026-09-07_repository-conversion-verification.json`
|
||||||
|
and `2026-09-07_repository-conversion-postcommit-verification.json`.
|
||||||
|
Verified rollback copies, NOT development roots:
|
||||||
|
- `/mnt/storage/src/.mosaic-stack-conversion-20260907T172430Z/`
|
||||||
|
- `/home/jwoltje/src/.mosaic-stack-dev-test.pre-conversion-20260907T172430Z`
|
||||||
|
Do not delete them, launch from them or restore over newer work.
|
||||||
|
|
||||||
|
## Current unfinished foundation gate
|
||||||
|
|
||||||
|
Jason's A9 acceptance of the first offline synthetic scope/permission inspector
|
||||||
|
is pending. Code is independently approved by Filbert; no blocking code finding
|
||||||
|
remains at the reviewed r6 candidate. Owner acceptance is not inferred from tests.
|
||||||
|
|
||||||
|
- Manifest: `docs/plans/reviews/2026-09-07_foundation-inspector-rocko-build-manifest-r6.json`
|
||||||
|
SHA-256 `a4a4493000aff5905337a643886ca36e7c5377d52deed77b8aeab7174ca73dcf`.
|
||||||
|
- Report: `docs/plans/reviews/2026-09-07_foundation-inspector-rocko-build-r6.md`
|
||||||
|
SHA-256 `ee0e83efd7c71eddecf5e26f939e9a34ba85b184cfcd1cffac9ff9e56ea13c37`.
|
||||||
|
- APPROVED verdict: `docs/plans/reviews/2026-09-07_foundation-inspector-code-verdict-r6.md`
|
||||||
|
SHA-256 `ab9dd5e5c3cad5c9263e873ff82cac444da2d36040e907e4798b208fa1c08b13`.
|
||||||
|
- Guide: `docs/plans/reviews/2026-09-07_foundation-inspector-demo.md`.
|
||||||
|
|
||||||
|
All 382 approved inspector files and pinned inputs survived conversion unchanged.
|
||||||
|
Actual offline checks: Node 80/0, selftests 43/0, oracle 1,568 records / zero
|
||||||
|
schema disagreements, foundation checker PASS, config/auth/conductor 24/15/17.
|
||||||
|
Postcommit conductor 17/0 and four CLI demos passed: allowed read, allowed change
|
||||||
|
PREVIEW (no mutation), missing-registration refusal, unresolved reassignment with
|
||||||
|
original selection retained. Demo inputs are separate synthetic scenarios.
|
||||||
|
|
||||||
|
`test-task.sh` and `test-release.sh` remain NOT RUN / DEFERRED under Jason's bounded
|
||||||
|
offline-demo ruling. No deployment/native/live/provider/security certification.
|
||||||
|
Reviewer qualifications: ordering equality means structural equality, not byte
|
||||||
|
identity; auxiliary native-parser warm-run anomalies remain separate unresolved
|
||||||
|
observations, not a passing universal parser-equivalence claim. Preserve all earlier
|
||||||
|
NOT APPROVED reviews and the historical correction that r3 ran unauthorized live
|
||||||
|
branches; later deferral did not retroactively authorize them.
|
||||||
|
|
||||||
|
## Ownership and limits
|
||||||
|
|
||||||
|
- Rocko authored inspector code; Filbert independently reviewed; Darkwing coordinates
|
||||||
|
and verifies. Keep the approved candidate frozen unless a new fix is authorized.
|
||||||
|
- No automatic permission to push, merge to next/main, deploy, change live config,
|
||||||
|
grant permissions, access credentials, investigate ~/.mosaic, or start new runtime
|
||||||
|
work. Local conversion authority is not authority for those activities.
|
||||||
|
- Preserve unrelated pending work. In particular `scripts/agent.sh`, `docs/TOOLS.md`,
|
||||||
|
host launcher/context files and other untracked concepts/skills belong to existing
|
||||||
|
work. Do not blanket-stage/reset/clean. Root logs and CURRENT remain uncommitted.
|
||||||
|
- Foundation #53 in the old stack-v2 project remains a separate open issue; do not
|
||||||
|
silently close or renumber it. Accepted historical SHA/path citations remain valid.
|
||||||
|
- Rocko's Archify C1 remains HELD for owner T2/T3 decisions. No lane reassignment.
|
||||||
|
- Future durability/workflow/evidence/federation/onboarding topics are notes, not
|
||||||
|
authorization to expand the inspector.
|
||||||
|
|
||||||
|
## Communications
|
||||||
|
|
||||||
|
Use only `tools/tmux/agent-send.sh`; sender `dragon-lin:darkwing`.
|
||||||
|
Rocko: `-L mosaic-fleet -s '=rocko'`; Filbert/Dewey:
|
||||||
|
`-L default -s '=filbert'` / `'=dewey'`.
|
||||||
|
Conversion notice delivered to Rocko. Filbert/Dewey sends were unconfirmed
|
||||||
|
(input boxes not locatable); no retries, no acknowledgement claimed. Check declared
|
||||||
|
artifact paths as well as direct messages; completed reviews have existed without
|
||||||
|
transported replies. Do not inspect private panes or blindly resend.
|
||||||
|
|
||||||
|
## Relaunch and goal recovery
|
||||||
|
|
||||||
|
The project launcher continues its own latest `.pi/state/darkwing/sessions/`
|
||||||
|
conversation by default. Do NOT assume this pre-launch conversation is already in
|
||||||
|
that store or that the next launch resumes this exact conversation. This handoff
|
||||||
|
is the durable bridge. No session-tree migration or launch was performed here.
|
||||||
|
|
||||||
|
The goal extension owns lifecycle. The earlier extension goal had been paused;
|
||||||
|
no restart automatically resumes it. Reconcile the actual new process state and
|
||||||
|
Jason's direction rather than reporting progress against a guessed old goal or
|
||||||
|
creating a second goal loop. Launch alone grants no new assignment.
|
||||||
|
|
||||||
|
This handoff and its CONTEXT pointer are documentation-only. Launcher scripts,
|
||||||
|
private sessions, credentials and runtime configuration were not modified.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# SOUL - researcher
|
||||||
|
|
||||||
|
You are the researcher seat of the Mosaic fleet. You are curious, methodical,
|
||||||
|
and precise. You cite what you know, admit what you do not, and never guess
|
||||||
|
when you can verify.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"agentVersion": 1,
|
||||||
|
"name": "researcher",
|
||||||
|
"role": "researcher",
|
||||||
|
"capabilities": { "tools": ["read", "bash"] }
|
||||||
|
}
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
|
||||||
vi.mock('../../commands/command-executor.service.js', () => ({
|
|
||||||
CommandExecutorService: class CommandExecutorService {},
|
|
||||||
}));
|
|
||||||
vi.mock('../routing/routing-engine.service.js', () => ({
|
|
||||||
RoutingEngineService: class RoutingEngineService {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { SessionsController } from '../sessions.controller.js';
|
|
||||||
import { ChatController } from '../../chat/chat.controller.js';
|
|
||||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
|
||||||
import type { AgentSession } from '../agent.service.js';
|
|
||||||
import type { SessionInfoDto } from '../session.dto.js';
|
|
||||||
|
|
||||||
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
|
|
||||||
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
|
||||||
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
|
|
||||||
|
|
||||||
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
|
|
||||||
return {
|
|
||||||
id: CONVERSATION_ID,
|
|
||||||
provider: 'test-provider',
|
|
||||||
modelId: 'test-model',
|
|
||||||
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
||||||
promptCount: 0,
|
|
||||||
channels: [],
|
|
||||||
durationMs: 0,
|
|
||||||
metrics: {
|
|
||||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
modelSwitches: 0,
|
|
||||||
messageCount: 0,
|
|
||||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAgentSession(owner = USER_A): AgentSession {
|
|
||||||
return {
|
|
||||||
id: CONVERSATION_ID,
|
|
||||||
provider: 'test-provider',
|
|
||||||
modelId: 'test-model',
|
|
||||||
piSession: {
|
|
||||||
thinkingLevel: 'off',
|
|
||||||
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
|
||||||
setThinkingLevel: vi.fn(),
|
|
||||||
abort: vi.fn().mockResolvedValue(undefined),
|
|
||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
|
||||||
dispose: vi.fn(),
|
|
||||||
getSessionStats: vi.fn(),
|
|
||||||
getContextUsage: vi.fn(),
|
|
||||||
} as unknown as AgentSession['piSession'],
|
|
||||||
listeners: new Set(),
|
|
||||||
unsubscribe: vi.fn(),
|
|
||||||
createdAt: Date.now(),
|
|
||||||
promptCount: 0,
|
|
||||||
channels: new Set(),
|
|
||||||
skillPromptAdditions: [],
|
|
||||||
sandboxDir: '/tmp',
|
|
||||||
allowedTools: null,
|
|
||||||
userId: owner.id,
|
|
||||||
tenantId: owner.tenantId,
|
|
||||||
metrics: {
|
|
||||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
modelSwitches: 0,
|
|
||||||
messageCount: 0,
|
|
||||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeScopedAgentService() {
|
|
||||||
const foreign = makeAgentSession(USER_A);
|
|
||||||
return {
|
|
||||||
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
|
|
||||||
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
|
|
||||||
),
|
|
||||||
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
|
||||||
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
|
|
||||||
),
|
|
||||||
destroySession: vi.fn(),
|
|
||||||
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
|
||||||
scope?.userId === USER_B.id ? undefined : foreign,
|
|
||||||
),
|
|
||||||
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
|
|
||||||
onEvent: vi.fn(() => vi.fn()),
|
|
||||||
addChannel: vi.fn(),
|
|
||||||
removeChannel: vi.fn(),
|
|
||||||
recordMessage: vi.fn(),
|
|
||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
|
||||||
it('requires explicit owner+tenant scope on protected session operations', () => {
|
|
||||||
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
|
||||||
|
|
||||||
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
|
|
||||||
expect(source).toContain('listSessions(scope: ActorTenantScope)');
|
|
||||||
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
|
|
||||||
expect(source).toContain(
|
|
||||||
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
|
||||||
);
|
|
||||||
expect(source).toContain(
|
|
||||||
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
|
||||||
);
|
|
||||||
expect(source).toContain(
|
|
||||||
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
|
|
||||||
);
|
|
||||||
expect(source).toContain('scope: ActorTenantScope,');
|
|
||||||
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
|
|
||||||
expect(source).not.toContain('scope?: ActorTenantScope');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
|
||||||
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
|
|
||||||
const agentService = makeScopedAgentService();
|
|
||||||
const controller = new SessionsController(agentService as never);
|
|
||||||
|
|
||||||
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
|
|
||||||
expect(agentService.listSessions).toHaveBeenCalledWith({
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not reveal another owner/tenant session by guessed id', () => {
|
|
||||||
const agentService = makeScopedAgentService();
|
|
||||||
const controller = new SessionsController(agentService as never);
|
|
||||||
|
|
||||||
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
|
|
||||||
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not terminate another owner/tenant session by guessed id', async () => {
|
|
||||||
const agentService = makeScopedAgentService();
|
|
||||||
const controller = new SessionsController(agentService as never);
|
|
||||||
|
|
||||||
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
|
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
expect(agentService.destroySession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
|
||||||
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
|
||||||
const agentService = makeScopedAgentService();
|
|
||||||
const controller = new ChatController(agentService as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
|
||||||
).rejects.toMatchObject({ status: 404 });
|
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
|
|
||||||
function makeGateway(agentService = makeScopedAgentService()) {
|
|
||||||
const brain = {
|
|
||||||
conversations: {
|
|
||||||
findById: vi.fn().mockResolvedValue(undefined),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
findMessages: vi.fn().mockResolvedValue([]),
|
|
||||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
|
|
||||||
const commandExecutor = { execute: vi.fn() };
|
|
||||||
const routingEngine = {
|
|
||||||
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
|
||||||
};
|
|
||||||
const gateway = new ChatGateway(
|
|
||||||
agentService as never,
|
|
||||||
{} as never,
|
|
||||||
brain as never,
|
|
||||||
commandRegistry as never,
|
|
||||||
commandExecutor as never,
|
|
||||||
routingEngine as never,
|
|
||||||
);
|
|
||||||
return { gateway, agentService };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSocket() {
|
|
||||||
return {
|
|
||||||
id: 'socket-b',
|
|
||||||
connected: true,
|
|
||||||
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
|
|
||||||
emit: vi.fn(),
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
|
||||||
const { gateway, agentService } = makeGateway();
|
|
||||||
const socket = makeSocket();
|
|
||||||
|
|
||||||
await gateway.handleMessage(socket as never, {
|
|
||||||
conversationId: CONVERSATION_ID,
|
|
||||||
content: 'attach to foreign session',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
expect(agentService.onEvent).not.toHaveBeenCalled();
|
|
||||||
expect(agentService.addChannel).not.toHaveBeenCalled();
|
|
||||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
|
||||||
'error',
|
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mutate thinking level on another owner/tenant session', () => {
|
|
||||||
const { gateway, agentService } = makeGateway();
|
|
||||||
const socket = makeSocket();
|
|
||||||
|
|
||||||
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
|
||||||
'error',
|
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
|
||||||
const { gateway, agentService } = makeGateway();
|
|
||||||
const socket = makeSocket();
|
|
||||||
|
|
||||||
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
|
||||||
'error',
|
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
|
|
||||||
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
|
|
||||||
*
|
|
||||||
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
|
|
||||||
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
|
|
||||||
* within the user path — so symlink escape attempts are caught too.
|
|
||||||
*
|
|
||||||
* @param userPath - The path provided by the agent (may be relative or absolute)
|
|
||||||
* @param sandboxDir - The allowed root directory (already validated on session creation)
|
|
||||||
* @returns The resolved absolute path, guaranteed to be within sandboxDir
|
|
||||||
*/
|
|
||||||
export function guardPath(userPath: string, sandboxDir: string): string {
|
|
||||||
const resolved = path.resolve(sandboxDir, userPath);
|
|
||||||
const sandboxResolved = fs.realpathSync.native(sandboxDir);
|
|
||||||
|
|
||||||
// Normalize both paths to resolve any symlinks in the sandbox root itself.
|
|
||||||
// For the user path, we check containment BEFORE resolving symlinks in the path
|
|
||||||
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
|
|
||||||
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
|
|
||||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a path without resolving symlinks in the user-provided portion.
|
|
||||||
* Use for paths that may not exist yet (creates, writes).
|
|
||||||
*
|
|
||||||
* Performs a lexical containment check only using path.resolve.
|
|
||||||
*/
|
|
||||||
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
|
|
||||||
const resolved = path.resolve(sandboxDir, userPath);
|
|
||||||
const sandboxAbs = path.resolve(sandboxDir);
|
|
||||||
|
|
||||||
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
|
|
||||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SandboxEscapeError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly userPath: string,
|
|
||||||
public readonly sandboxDir: string,
|
|
||||||
public readonly resolvedPath: string,
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
`Path escape attempt blocked: "${userPath}" resolves to "${resolvedPath}" which is outside sandbox "${sandboxDir}"`,
|
|
||||||
);
|
|
||||||
this.name = 'SandboxEscapeError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import { validateSync } from 'class-validator';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { SendMessageDto } from '../../conversations/conversations.dto.js';
|
|
||||||
import { ChatRequestDto } from '../chat.dto.js';
|
|
||||||
import { validateSocketSession } from '../chat.gateway-auth.js';
|
|
||||||
|
|
||||||
describe('Chat controller source hardening', () => {
|
|
||||||
it('applies AuthGuard and reads the current user', () => {
|
|
||||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
|
||||||
|
|
||||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
|
||||||
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
|
|
||||||
expect(source).toContain('const scope = scopeFromUser(user);');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('WebSocket session authentication', () => {
|
|
||||||
it('returns null when the handshake does not resolve to a session', async () => {
|
|
||||||
const result = await validateSocketSession(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
api: {
|
|
||||||
getSession: vi.fn().mockResolvedValue(null),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the resolved session when Better Auth accepts the headers', async () => {
|
|
||||||
const session = { user: { id: 'user-1' }, session: { id: 'session-1' } };
|
|
||||||
|
|
||||||
const result = await validateSocketSession(
|
|
||||||
{ cookie: 'session=abc' },
|
|
||||||
{
|
|
||||||
api: {
|
|
||||||
getSession: vi.fn().mockResolvedValue(session),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual(session);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Chat DTO validation', () => {
|
|
||||||
it('rejects unsupported message roles', () => {
|
|
||||||
const dto = Object.assign(new SendMessageDto(), {
|
|
||||||
content: 'hello',
|
|
||||||
role: 'moderator',
|
|
||||||
});
|
|
||||||
|
|
||||||
const errors = validateSync(dto);
|
|
||||||
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects oversized conversation message content above 10000 characters', () => {
|
|
||||||
const dto = Object.assign(new SendMessageDto(), {
|
|
||||||
content: 'x'.repeat(10_001),
|
|
||||||
role: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
const errors = validateSync(dto);
|
|
||||||
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects oversized chat content above 10000 characters', () => {
|
|
||||||
const dto = Object.assign(new ChatRequestDto(), {
|
|
||||||
content: 'x'.repeat(10_001),
|
|
||||||
});
|
|
||||||
|
|
||||||
const errors = validateSync(dto);
|
|
||||||
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Logger,
|
|
||||||
ForbiddenException,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
NotFoundException,
|
|
||||||
Inject,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
|
||||||
import { Throttle } from '@nestjs/throttler';
|
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { ChatRequestDto } from './chat.dto.js';
|
|
||||||
|
|
||||||
interface ChatResponse {
|
|
||||||
conversationId: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/chat')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class ChatController {
|
|
||||||
private readonly logger = new Logger(ChatController.name);
|
|
||||||
|
|
||||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
|
||||||
async chat(
|
|
||||||
@Body() body: ChatRequestDto,
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
): Promise<ChatResponse> {
|
|
||||||
const conversationId = body.conversationId ?? uuid();
|
|
||||||
const scope = scopeFromUser(user);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
|
||||||
if (!agentSession) {
|
|
||||||
agentSession = await this.agentService.createSession(conversationId, {
|
|
||||||
userId: scope.userId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ForbiddenException) {
|
|
||||||
throw new NotFoundException('Session not found');
|
|
||||||
}
|
|
||||||
this.logger.error(
|
|
||||||
`Session creation failed for conversation=${conversationId}`,
|
|
||||||
err instanceof Error ? err.stack : String(err),
|
|
||||||
);
|
|
||||||
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
|
|
||||||
|
|
||||||
let responseText = '';
|
|
||||||
|
|
||||||
const done = new Promise<void>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
|
|
||||||
reject(new Error('Agent response timed out'));
|
|
||||||
}, 120_000);
|
|
||||||
|
|
||||||
const cleanup = this.agentService.onEvent(
|
|
||||||
conversationId,
|
|
||||||
(event: AgentSessionEvent) => {
|
|
||||||
if (
|
|
||||||
event.type === 'message_update' &&
|
|
||||||
event.assistantMessageEvent.type === 'text_delta'
|
|
||||||
) {
|
|
||||||
responseText += event.assistantMessageEvent.delta;
|
|
||||||
}
|
|
||||||
if (event.type === 'agent_end') {
|
|
||||||
clearTimeout(timer);
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scope,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.agentService.prompt(conversationId, body.content, scope);
|
|
||||||
await done;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof HttpException) throw err;
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.includes('timed out')) {
|
|
||||||
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
|
||||||
}
|
|
||||||
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
|
|
||||||
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { conversationId, text: responseText };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
|
||||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class ChatRequestDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
conversationId?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(10_000)
|
|
||||||
content!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatSocketMessageDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
conversationId?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(10_000)
|
|
||||||
content!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(255)
|
|
||||||
provider?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(255)
|
|
||||||
modelId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
agentId?: string;
|
|
||||||
|
|
||||||
/** Validated channel attachment references; binary content is not embedded. */
|
|
||||||
attachments?: readonly ChannelAttachmentDto[];
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
|
||||||
import { ChatGateway } from './chat.gateway.js';
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'gc',
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
approvalId: 'approval-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildGateway(commandExecutor: {
|
|
||||||
execute: ReturnType<typeof vi.fn>;
|
|
||||||
createApproval: ReturnType<typeof vi.fn>;
|
|
||||||
}): ChatGateway {
|
|
||||||
return new ChatGateway(
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
commandExecutor as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ChatGateway command approval ingress', () => {
|
|
||||||
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
|
|
||||||
const commandExecutor = {
|
|
||||||
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
|
|
||||||
createApproval: vi.fn(),
|
|
||||||
};
|
|
||||||
const gateway = buildGateway(commandExecutor);
|
|
||||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
|
||||||
|
|
||||||
await gateway.handleCommandExecute(client as never, payload);
|
|
||||||
|
|
||||||
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
|
|
||||||
userId: 'admin-1',
|
|
||||||
tenantId: 'admin-1',
|
|
||||||
});
|
|
||||||
expect(client.emit).toHaveBeenCalledWith(
|
|
||||||
'command:result',
|
|
||||||
expect.objectContaining({ success: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
|
|
||||||
const commandExecutor = {
|
|
||||||
execute: vi.fn(),
|
|
||||||
createApproval: vi.fn().mockResolvedValue({
|
|
||||||
approvalId: 'approval-1',
|
|
||||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const gateway = buildGateway(commandExecutor);
|
|
||||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
|
||||||
|
|
||||||
await gateway.handleCommandApproval(client as never, {
|
|
||||||
command: 'gc',
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
|
||||||
{ command: 'gc', conversationId: 'conversation-1' },
|
|
||||||
{ userId: 'admin-1', tenantId: 'admin-1' },
|
|
||||||
);
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('command:approval', {
|
|
||||||
command: 'gc',
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
success: true,
|
|
||||||
approvalId: 'approval-1',
|
|
||||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
|
||||||
import { CommandsModule } from '../commands/commands.module.js';
|
|
||||||
import { ChatGateway } from './chat.gateway.js';
|
|
||||||
import { ChatController } from './chat.controller.js';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [forwardRef(() => CommandsModule)],
|
|
||||||
controllers: [ChatController],
|
|
||||||
providers: [ChatGateway],
|
|
||||||
exports: [ChatGateway],
|
|
||||||
})
|
|
||||||
export class ChatModule {}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ConversationsController } from './conversations.controller.js';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [ConversationsController],
|
|
||||||
})
|
|
||||||
export class ConversationsModule {}
|
|
||||||
@@ -1,715 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
createDiscordIngressEnvelope,
|
|
||||||
verifyDiscordIngressEnvelope,
|
|
||||||
DiscordPlugin,
|
|
||||||
type DiscordIngressPayload,
|
|
||||||
parseDiscordInteractionBindings,
|
|
||||||
resolveDiscordInteractionActorId,
|
|
||||||
resolveDiscordInteractionBinding,
|
|
||||||
} from '@mosaicstack/discord-plugin';
|
|
||||||
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
|
|
||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
|
||||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
|
||||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
|
||||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
|
||||||
|
|
||||||
const SERVICE_TOKEN = 'test-service-token';
|
|
||||||
const ENV_KEYS = [
|
|
||||||
'DISCORD_SERVICE_TOKEN',
|
|
||||||
'DISCORD_SERVICE_USER_ID',
|
|
||||||
'DISCORD_SERVICE_TENANT_ID',
|
|
||||||
'DISCORD_INTERACTION_BINDINGS',
|
|
||||||
'DISCORD_ALLOWED_GUILD_IDS',
|
|
||||||
'DISCORD_ALLOWED_CHANNEL_IDS',
|
|
||||||
'DISCORD_ALLOWED_USER_IDS',
|
|
||||||
'MOSAIC_AGENT_NAME',
|
|
||||||
'MOSAIC_AGENT_CONFIG_ID',
|
|
||||||
] as const;
|
|
||||||
const savedEnv = new Map<string, string | undefined>();
|
|
||||||
|
|
||||||
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
|
||||||
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
|
||||||
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
|
||||||
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
|
||||||
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova';
|
|
||||||
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
|
|
||||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
|
||||||
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
agentConfigId: 'agent-config-nova',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
pairedUsers: {
|
|
||||||
'user-001': {
|
|
||||||
role: role === 'admin' ? 'admin' : 'operator',
|
|
||||||
mosaicUserId: 'mosaic-admin-001',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach((): void => {
|
|
||||||
for (const key of ENV_KEYS) {
|
|
||||||
const value = savedEnv.get(key);
|
|
||||||
if (value === undefined) delete process.env[key];
|
|
||||||
else process.env[key] = value;
|
|
||||||
}
|
|
||||||
savedEnv.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
|
|
||||||
const entries = new Map<string, string>();
|
|
||||||
const db = {
|
|
||||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
|
||||||
};
|
|
||||||
const redis = {
|
|
||||||
get: async (key: string) => entries.get(key) ?? null,
|
|
||||||
set: async (key: string, value: string) => entries.set(key, value),
|
|
||||||
del: async (key: string) => Number(entries.delete(key)),
|
|
||||||
};
|
|
||||||
return new CommandAuthorizationService(db as never, redis);
|
|
||||||
}
|
|
||||||
|
|
||||||
function discordGateway(role: 'admin' | 'member'): {
|
|
||||||
gateway: ChatGateway;
|
|
||||||
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
|
|
||||||
consumedActions: Array<{ actorId: string; correlationId: string }>;
|
|
||||||
durable: { getSnapshot: ReturnType<typeof vi.fn> };
|
|
||||||
audit: { record: ReturnType<typeof vi.fn> };
|
|
||||||
} {
|
|
||||||
const authorization = commandAuthorization(role);
|
|
||||||
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
|
|
||||||
const durable = {
|
|
||||||
getSnapshot: vi.fn().mockResolvedValue({
|
|
||||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const audit = { record: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const runtimeRegistry = new RuntimeProviderService(
|
|
||||||
{
|
|
||||||
require: () => ({
|
|
||||||
capabilities: async () => ({ supported: ['session.terminate'] }),
|
|
||||||
terminate: async () => undefined,
|
|
||||||
}),
|
|
||||||
} as never,
|
|
||||||
{ record: async () => undefined } as never,
|
|
||||||
{
|
|
||||||
consume: async (approvalId, action) => {
|
|
||||||
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
|
|
||||||
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
gateway: new ChatGateway(
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
authorization,
|
|
||||||
runtimeRegistry,
|
|
||||||
durable as never,
|
|
||||||
audit as never,
|
|
||||||
),
|
|
||||||
client: { data: { discordService: true }, emit: vi.fn() },
|
|
||||||
consumedActions,
|
|
||||||
durable,
|
|
||||||
audit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ingressEnvelope(
|
|
||||||
content: string,
|
|
||||||
messageId: string,
|
|
||||||
overrides: Partial<DiscordIngressPayload> = {},
|
|
||||||
): ReturnType<typeof createDiscordIngressEnvelope> {
|
|
||||||
return createDiscordIngressEnvelope(
|
|
||||||
createPayload({ content, messageId, ...overrides }),
|
|
||||||
SERVICE_TOKEN,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
|
||||||
return {
|
|
||||||
correlationId: 'correlation-001',
|
|
||||||
messageId: 'discord-message-001',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
userId: 'user-001',
|
|
||||||
conversationId: 'Nova:discord:channel-001',
|
|
||||||
content: 'hello Tess',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Discord ingress security', () => {
|
|
||||||
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
|
||||||
const [binding] = parseDiscordInteractionBindings(
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
agentConfigId: 'agent-config-nova',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
pairedUsers: { 'user-001': 'admin' },
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
|
|
||||||
).toEqual(binding);
|
|
||||||
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds a differently named configured interaction instance without code changes', () => {
|
|
||||||
const binding = resolveDiscordInteractionBinding(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
agentConfigId: 'agent-config-nova',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'guild-001',
|
|
||||||
'channel-001',
|
|
||||||
'user-001',
|
|
||||||
'send',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(binding?.instanceId).toBe('Nova');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts only the configured Discord service identity', () => {
|
|
||||||
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
|
||||||
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
|
||||||
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unauthenticated or tampered service envelopes', () => {
|
|
||||||
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
|
|
||||||
|
|
||||||
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
|
|
||||||
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
|
|
||||||
expect(
|
|
||||||
verifyDiscordIngressEnvelope(
|
|
||||||
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
|
|
||||||
SERVICE_TOKEN,
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['guild', { guildId: 'unlisted-guild' }],
|
|
||||||
['channel', { channelId: 'unlisted-channel' }],
|
|
||||||
['user', { userId: 'unlisted-user' }],
|
|
||||||
])(
|
|
||||||
'rejects an unallowlisted Discord %s',
|
|
||||||
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
|
|
||||||
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
|
||||||
guildIds: ['guild-001'],
|
|
||||||
channelIds: ['channel-001'],
|
|
||||||
userIds: ['user-001'],
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
|
|
||||||
const payload = createPayload({
|
|
||||||
correlationId: 'correlation-trace-123',
|
|
||||||
messageId: 'discord-snowflake-987',
|
|
||||||
});
|
|
||||||
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
|
||||||
guildIds: ['guild-001'],
|
|
||||||
channelIds: ['channel-001'],
|
|
||||||
userIds: ['user-001'],
|
|
||||||
}),
|
|
||||||
).toEqual(payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
|
|
||||||
const replayProtector = new DiscordReplayProtector(60_000, 2);
|
|
||||||
|
|
||||||
expect(replayProtector.claim('discord-message-001')).toBe(true);
|
|
||||||
expect(replayProtector.claim('discord-message-001')).toBe(false);
|
|
||||||
expect(replayProtector.claim('discord-message-002')).toBe(true);
|
|
||||||
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
|
||||||
expect(replayProtector.size).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client, consumedActions } = discordGateway('admin');
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'approve-message', {
|
|
||||||
correlationId: 'approval-ingress-correlation',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const approval = client.emit.mock.calls.find(
|
|
||||||
([event]) => event === 'discord:approval',
|
|
||||||
)?.[1] as {
|
|
||||||
approvalId: string;
|
|
||||||
success: boolean;
|
|
||||||
};
|
|
||||||
expect(approval.success).toBe(true);
|
|
||||||
|
|
||||||
await gateway.handleDiscordStop(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', {
|
|
||||||
correlationId: 'stop-ingress-correlation',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
|
||||||
correlationId: 'stop-ingress-correlation',
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
expect(consumedActions).toEqual([
|
|
||||||
{
|
|
||||||
actorId: 'mosaic-admin-001',
|
|
||||||
correlationId: expect.stringMatching(/^discord-action:v1:/),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('audits a Discord mint-side authorization denial', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client, audit } = discordGateway('member');
|
|
||||||
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'denied-approve'),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
|
||||||
correlationId: 'correlation-001',
|
|
||||||
success: false,
|
|
||||||
approvalId: undefined,
|
|
||||||
expiresAt: undefined,
|
|
||||||
});
|
|
||||||
expect(audit.record).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
outcome: 'denied',
|
|
||||||
operation: 'session.terminate',
|
|
||||||
errorCode: 'policy_denied',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects approval when the durable session targets a different logical agent', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client, durable } = discordGateway('admin');
|
|
||||||
durable.getSnapshot.mockResolvedValueOnce({
|
|
||||||
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'mismatched-agent-approve'),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
|
||||||
correlationId: 'correlation-001',
|
|
||||||
success: false,
|
|
||||||
approvalId: undefined,
|
|
||||||
expiresAt: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects privileged envelopes with a forged current conversation route', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client } = discordGateway('admin');
|
|
||||||
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'forged-approval-route', {
|
|
||||||
conversationId: 'Nova:discord:other-channel',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await gateway.handleDiscordStop(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/stop forged', 'forged-stop-route', {
|
|
||||||
conversationId: 'Nova:discord:other-channel',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything());
|
|
||||||
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client } = discordGateway('member');
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'member-approve'),
|
|
||||||
);
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
|
||||||
correlationId: 'correlation-001',
|
|
||||||
success: false,
|
|
||||||
approvalId: undefined,
|
|
||||||
expiresAt: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
|
|
||||||
await gateway.handleDiscordStop(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/stop forged', 'unpaired-stop'),
|
|
||||||
);
|
|
||||||
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects replaying a Discord-created termination approval', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client } = discordGateway('admin');
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'replay-approve', {
|
|
||||||
correlationId: 'replay-approval-correlation',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const approval = client.emit.mock.calls.find(
|
|
||||||
([event]) => event === 'discord:approval',
|
|
||||||
)?.[1] as {
|
|
||||||
approvalId: string;
|
|
||||||
};
|
|
||||||
await gateway.handleDiscordStop(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', {
|
|
||||||
correlationId: 'replay-stop-correlation-one',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await gateway.handleDiscordStop(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', {
|
|
||||||
correlationId: 'replay-stop-correlation-two',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
|
|
||||||
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
'https://user:[email protected]/diagram.png',
|
|
||||||
'https://cdn.example.test/diagram.png?token=secret',
|
|
||||||
'https://cdn.example.test/diagram.png?X-Amz-Signature=secret',
|
|
||||||
'https://cdn.example.test/diagram.png?auth=secret',
|
|
||||||
'https://cdn.example.test/diagram.png?hm=secret',
|
|
||||||
])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client } = discordGateway('admin');
|
|
||||||
|
|
||||||
await gateway.handleMessage(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('', `credential-url-${url.length}`, {
|
|
||||||
conversationId: 'Nova:discord:channel-001',
|
|
||||||
attachments: [
|
|
||||||
{ id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
|
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
agentConfigId: 'agent-config-nova',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
pairedUsers: {
|
|
||||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
instanceId: 'Orion',
|
|
||||||
agentConfigId: 'agent-config-orion',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-002',
|
|
||||||
pairedUsers: {
|
|
||||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const session = {
|
|
||||||
provider: 'configured-provider',
|
|
||||||
modelId: 'configured-model',
|
|
||||||
piSession: {
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const createSession = vi.fn().mockResolvedValue(session);
|
|
||||||
const agentService = {
|
|
||||||
getSession: vi.fn().mockReturnValue(undefined),
|
|
||||||
createSession,
|
|
||||||
recordMessage: vi.fn(),
|
|
||||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
|
||||||
addChannel: vi.fn(),
|
|
||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
const brain = {
|
|
||||||
agents: {
|
|
||||||
findById: vi.fn((id: string) =>
|
|
||||||
Promise.resolve({
|
|
||||||
id,
|
|
||||||
name: id === 'agent-config-orion' ? 'Orion' : 'Nova',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
conversations: {
|
|
||||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
|
||||||
findMessages: vi.fn().mockResolvedValue([]),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const routingEngine = { resolve: vi.fn() };
|
|
||||||
const gateway = new ChatGateway(
|
|
||||||
agentService as never,
|
|
||||||
{} as never,
|
|
||||||
brain as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
routingEngine as never,
|
|
||||||
);
|
|
||||||
const client = {
|
|
||||||
id: 'discord-client-new-session',
|
|
||||||
data: { discordService: true },
|
|
||||||
emit: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await gateway.handleMessage(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('start configured session', 'configured-session-001', {
|
|
||||||
conversationId: 'Nova:discord:channel-001',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await gateway.handleMessage(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('start second configured session', 'configured-session-002', {
|
|
||||||
channelId: 'channel-002',
|
|
||||||
conversationId: 'Orion:discord:channel-002',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(createSession).toHaveBeenCalledWith(
|
|
||||||
'Nova:discord:channel-001',
|
|
||||||
expect.objectContaining({
|
|
||||||
agentConfigId: 'agent-config-nova',
|
|
||||||
userId: 'discord-service',
|
|
||||||
tenantId: 'tenant-discord',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(createSession).toHaveBeenCalledWith(
|
|
||||||
'Orion:discord:channel-002',
|
|
||||||
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
|
|
||||||
);
|
|
||||||
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retains validated persisted attachments in resumed conversation history', async () => {
|
|
||||||
const attachment = {
|
|
||||||
id: 'attachment-history',
|
|
||||||
name: 'diagram.png',
|
|
||||||
url: 'https://cdn.example.test/diagram.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
sizeBytes: 4_096,
|
|
||||||
};
|
|
||||||
const gateway = new ChatGateway(
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{
|
|
||||||
conversations: {
|
|
||||||
findMessages: vi.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: '',
|
|
||||||
createdAt: new Date('2026-07-14T12:00:00.000Z'),
|
|
||||||
metadata: { channelAttachments: [attachment] },
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
) as unknown as {
|
|
||||||
loadConversationHistory(
|
|
||||||
conversationId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<Array<{ attachments?: readonly (typeof attachment)[] }>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
gateway.loadConversationHistory('Nova:discord:channel-001', 'discord-service'),
|
|
||||||
).resolves.toEqual([expect.objectContaining({ attachments: [attachment] })]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects malformed signed attachment payloads before gateway dispatch', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client } = discordGateway('admin');
|
|
||||||
const malformedPayload: Record<string, unknown> = {
|
|
||||||
...createPayload({
|
|
||||||
messageId: 'malformed-attachments-001',
|
|
||||||
conversationId: 'Nova:discord:channel-001',
|
|
||||||
}),
|
|
||||||
attachments: { id: 'not-an-array' },
|
|
||||||
};
|
|
||||||
const envelope = createDiscordIngressEnvelope(
|
|
||||||
malformedPayload as unknown as DiscordIngressPayload,
|
|
||||||
SERVICE_TOKEN,
|
|
||||||
);
|
|
||||||
|
|
||||||
await gateway.handleMessage(client as never, envelope);
|
|
||||||
|
|
||||||
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const addMessage = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const session = {
|
|
||||||
provider: 'test-provider',
|
|
||||||
modelId: 'test-model',
|
|
||||||
piSession: {
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const agentService = {
|
|
||||||
getSession: vi.fn().mockReturnValue(session),
|
|
||||||
recordMessage: vi.fn(),
|
|
||||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
|
||||||
addChannel: vi.fn(),
|
|
||||||
prompt,
|
|
||||||
};
|
|
||||||
const brain = {
|
|
||||||
conversations: {
|
|
||||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
addMessage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const gateway = new ChatGateway(
|
|
||||||
agentService as never,
|
|
||||||
{} as never,
|
|
||||||
brain as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
const client = {
|
|
||||||
id: 'discord-client-001',
|
|
||||||
data: { discordService: true },
|
|
||||||
emit: vi.fn(),
|
|
||||||
};
|
|
||||||
const attachment = {
|
|
||||||
id: 'attachment-001',
|
|
||||||
name: 'diagram.png',
|
|
||||||
url: 'https://cdn.example.test/diagram.png',
|
|
||||||
contentType: 'image/png',
|
|
||||||
sizeBytes: 4_096,
|
|
||||||
};
|
|
||||||
|
|
||||||
await gateway.handleMessage(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('', 'attachment-message-001', {
|
|
||||||
conversationId: 'Nova:discord:channel-001',
|
|
||||||
attachments: [attachment],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const expectedAttachment = {
|
|
||||||
id: attachment.id,
|
|
||||||
name: attachment.name,
|
|
||||||
url: attachment.url,
|
|
||||||
mimeType: attachment.contentType,
|
|
||||||
sizeBytes: attachment.sizeBytes,
|
|
||||||
};
|
|
||||||
expect(prompt).toHaveBeenCalledWith(
|
|
||||||
'Nova:discord:channel-001',
|
|
||||||
'',
|
|
||||||
{ userId: 'discord-service', tenantId: 'tenant-discord' },
|
|
||||||
[expectedAttachment],
|
|
||||||
);
|
|
||||||
expect(addMessage).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
conversationId: 'Nova:discord:channel-001',
|
|
||||||
metadata: expect.objectContaining({ channelAttachments: [expectedAttachment] }),
|
|
||||||
}),
|
|
||||||
'discord-service',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a thread message through its allowed bound parent channel', () => {
|
|
||||||
const emitted = vi.fn();
|
|
||||||
const plugin = new DiscordPlugin({
|
|
||||||
token: 'unused',
|
|
||||||
gatewayUrl: 'http://unused',
|
|
||||||
serviceToken: SERVICE_TOKEN,
|
|
||||||
allowedGuildIds: ['guild-001'],
|
|
||||||
allowedChannelIds: ['channel-001'],
|
|
||||||
allowedUserIds: ['user-001'],
|
|
||||||
interactionBindings: [
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
agentConfigId: 'agent-config-nova',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const internals = plugin as unknown as {
|
|
||||||
client: { user: { id: string } };
|
|
||||||
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
|
||||||
handleDiscordMessage(message: unknown): void;
|
|
||||||
};
|
|
||||||
internals.client = { user: { id: 'bot-001' } };
|
|
||||||
internals.socket = { connected: true, emit: emitted };
|
|
||||||
internals.handleDiscordMessage({
|
|
||||||
id: 'thread-message',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'thread-001',
|
|
||||||
author: { id: 'user-001', bot: false },
|
|
||||||
mentions: { has: () => true },
|
|
||||||
content: '<@bot-001> hello from thread',
|
|
||||||
channel: { parentId: 'channel-001' },
|
|
||||||
attachments: new Map(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, envelope] = emitted.mock.calls[0] as [
|
|
||||||
string,
|
|
||||||
ReturnType<typeof createDiscordIngressEnvelope>,
|
|
||||||
];
|
|
||||||
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { ReloadService } from './reload.service.js';
|
|
||||||
|
|
||||||
function createMockCommandRegistry() {
|
|
||||||
return {
|
|
||||||
getManifest: vi.fn().mockReturnValue({
|
|
||||||
version: 1,
|
|
||||||
commands: [],
|
|
||||||
skills: [],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createService() {
|
|
||||||
const registry = createMockCommandRegistry();
|
|
||||||
const service = new ReloadService(registry as never);
|
|
||||||
return { service, registry };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ReloadService', () => {
|
|
||||||
it('reload() calls onUnload then onLoad for registered MosaicPlugin', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
|
|
||||||
const callOrder: string[] = [];
|
|
||||||
const mockPlugin = {
|
|
||||||
pluginName: 'test-plugin',
|
|
||||||
onLoad: vi.fn().mockImplementation(() => {
|
|
||||||
callOrder.push('onLoad');
|
|
||||||
return Promise.resolve();
|
|
||||||
}),
|
|
||||||
onUnload: vi.fn().mockImplementation(() => {
|
|
||||||
callOrder.push('onUnload');
|
|
||||||
return Promise.resolve();
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
service.registerPlugin('test-plugin', mockPlugin);
|
|
||||||
const result = await service.reload('command');
|
|
||||||
|
|
||||||
expect(mockPlugin.onUnload).toHaveBeenCalledOnce();
|
|
||||||
expect(mockPlugin.onLoad).toHaveBeenCalledOnce();
|
|
||||||
expect(callOrder).toEqual(['onUnload', 'onLoad']);
|
|
||||||
expect(result.message).toContain('test-plugin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reload() continues if one plugin throws during onUnload', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
|
|
||||||
const badPlugin = {
|
|
||||||
pluginName: 'bad-plugin',
|
|
||||||
onLoad: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onUnload: vi.fn().mockRejectedValue(new Error('unload failed')),
|
|
||||||
};
|
|
||||||
|
|
||||||
service.registerPlugin('bad-plugin', badPlugin);
|
|
||||||
const result = await service.reload('command');
|
|
||||||
|
|
||||||
expect(result.message).toContain('bad-plugin');
|
|
||||||
expect(result.message).toContain('unload failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reload() skips non-MosaicPlugin objects', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
|
|
||||||
const notAPlugin = { foo: 'bar' };
|
|
||||||
service.registerPlugin('not-a-plugin', notAPlugin);
|
|
||||||
|
|
||||||
// Should not throw
|
|
||||||
const result = await service.reload('command');
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.message).not.toContain('not-a-plugin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reload() returns SystemReloadPayload with commands, skills, providers, message', async () => {
|
|
||||||
const { service, registry } = createService();
|
|
||||||
registry.getManifest.mockReturnValue({
|
|
||||||
version: 1,
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
name: 'test',
|
|
||||||
description: 'test cmd',
|
|
||||||
aliases: [],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
skills: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.reload('rest');
|
|
||||||
|
|
||||||
expect(result).toHaveProperty('commands');
|
|
||||||
expect(result).toHaveProperty('skills');
|
|
||||||
expect(result).toHaveProperty('providers');
|
|
||||||
expect(result).toHaveProperty('message');
|
|
||||||
expect(result.commands).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registerPlugin() logs plugin registration', () => {
|
|
||||||
const { service } = createService();
|
|
||||||
|
|
||||||
// Should not throw and should register
|
|
||||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { TeamsService } from './teams.service.js';
|
|
||||||
|
|
||||||
@Controller('api/teams')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class TeamsController {
|
|
||||||
constructor(private readonly teams: TeamsService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async list() {
|
|
||||||
return this.teams.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':teamId')
|
|
||||||
async findOne(@Param('teamId') teamId: string) {
|
|
||||||
return this.teams.findById(teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':teamId/members')
|
|
||||||
async listMembers(@Param('teamId') teamId: string) {
|
|
||||||
return this.teams.listMembers(teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':teamId/members/:userId')
|
|
||||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
|
||||||
const isMember = await this.teams.isMember(teamId, userId);
|
|
||||||
return { isMember };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { WorkspaceService } from './workspace.service.js';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
describe('WorkspaceService', () => {
|
|
||||||
let service: WorkspaceService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new WorkspaceService();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolvePath', () => {
|
|
||||||
it('resolves user workspace path', () => {
|
|
||||||
const result = service.resolvePath({
|
|
||||||
id: 'proj1',
|
|
||||||
ownerType: 'user',
|
|
||||||
userId: 'user1',
|
|
||||||
teamId: null,
|
|
||||||
});
|
|
||||||
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves team workspace path', () => {
|
|
||||||
const result = service.resolvePath({
|
|
||||||
id: 'proj1',
|
|
||||||
ownerType: 'team',
|
|
||||||
userId: 'user1',
|
|
||||||
teamId: 'team1',
|
|
||||||
});
|
|
||||||
expect(result).toContain(path.join('teams', 'team1', 'proj1'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to user path when ownerType is team but teamId is null', () => {
|
|
||||||
const result = service.resolvePath({
|
|
||||||
id: 'proj1',
|
|
||||||
ownerType: 'team',
|
|
||||||
userId: 'user1',
|
|
||||||
teamId: null,
|
|
||||||
});
|
|
||||||
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses MOSAIC_ROOT env var as the base path', () => {
|
|
||||||
const originalRoot = process.env['MOSAIC_ROOT'];
|
|
||||||
process.env['MOSAIC_ROOT'] = '/custom/root';
|
|
||||||
const customService = new WorkspaceService();
|
|
||||||
const result = customService.resolvePath({
|
|
||||||
id: 'proj1',
|
|
||||||
ownerType: 'user',
|
|
||||||
userId: 'user1',
|
|
||||||
teamId: null,
|
|
||||||
});
|
|
||||||
expect(result).toMatch(/^\/custom\/root/);
|
|
||||||
// Restore
|
|
||||||
if (originalRoot === undefined) {
|
|
||||||
delete process.env['MOSAIC_ROOT'];
|
|
||||||
} else {
|
|
||||||
process.env['MOSAIC_ROOT'] = originalRoot;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to /opt/mosaic when MOSAIC_ROOT is unset', () => {
|
|
||||||
const originalRoot = process.env['MOSAIC_ROOT'];
|
|
||||||
delete process.env['MOSAIC_ROOT'];
|
|
||||||
const defaultService = new WorkspaceService();
|
|
||||||
const result = defaultService.resolvePath({
|
|
||||||
id: 'proj2',
|
|
||||||
ownerType: 'user',
|
|
||||||
userId: 'user2',
|
|
||||||
teamId: null,
|
|
||||||
});
|
|
||||||
expect(result).toMatch(/^\/opt\/mosaic/);
|
|
||||||
// Restore
|
|
||||||
if (originalRoot !== undefined) {
|
|
||||||
process.env['MOSAIC_ROOT'] = originalRoot;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
|
||||||
|
|
||||||
test.describe('Chat page', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
|
||||||
// If login failed (no seeded user in env) we may be on /login — skip
|
|
||||||
const url = page.url();
|
|
||||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('chat page loads and shows the welcome message or conversation list', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
// Either there are conversations listed or the welcome empty-state is shown
|
|
||||||
const hasWelcome = await page
|
|
||||||
.getByRole('heading', { name: /welcome to mosaic chat/i })
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
const hasConversationPanel = await page
|
|
||||||
.locator('[data-testid="conversation-list"], nav, aside')
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
expect(hasWelcome || hasConversationPanel).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('new conversation button is visible', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
// "Start new conversation" button or a "+" button in the sidebar
|
|
||||||
const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
|
||||||
await expect(newConvButton).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking new conversation shows a chat input area', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
// Find any button that creates a new conversation
|
|
||||||
const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
|
||||||
await newBtn.click();
|
|
||||||
// After creating, a text input for sending messages should appear
|
|
||||||
const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first();
|
|
||||||
await expect(chatInput).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar navigation is present on chat page', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
// The app-shell sidebar should be visible
|
|
||||||
await expect(page.getByRole('link', { name: /chat/i }).first()).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
export const TEST_USER = {
|
|
||||||
email: process.env['E2E_USER_EMAIL'] ?? '[email protected]',
|
|
||||||
password: process.env['E2E_USER_PASSWORD'] ?? 'password123',
|
|
||||||
name: 'E2E Test User',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ADMIN_USER = {
|
|
||||||
email: process.env['E2E_ADMIN_EMAIL'] ?? '[email protected]',
|
|
||||||
password: process.env['E2E_ADMIN_PASSWORD'] ?? 'adminpass123',
|
|
||||||
name: 'E2E Admin User',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill the login form and submit. Waits for navigation after success.
|
|
||||||
*/
|
|
||||||
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
|
|
||||||
await page.goto('/login');
|
|
||||||
await page.getByLabel('Email').fill(email);
|
|
||||||
await page.getByLabel('Password').fill(password);
|
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
|
||||||
}
|
|
||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import "./.next/types/routes.d.ts";
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { NextConfig } from 'next';
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
output: 'standalone',
|
|
||||||
transpilePackages: ['@mosaicstack/design-tokens'],
|
|
||||||
|
|
||||||
// Enable gzip/brotli compression for all responses.
|
|
||||||
compress: true,
|
|
||||||
|
|
||||||
// Reduce bundle size: disable source maps in production builds.
|
|
||||||
productionBrowserSourceMaps: false,
|
|
||||||
|
|
||||||
// Image optimisation: allow the gateway origin as an external image source.
|
|
||||||
images: {
|
|
||||||
formats: ['image/avif', 'image/webp'],
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: '**',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Experimental: enable React compiler for automatic memoisation (Next 15+).
|
|
||||||
// Falls back gracefully if the compiler plugin is not installed.
|
|
||||||
experimental: {
|
|
||||||
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
|
|
||||||
// turbo: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Playwright E2E configuration for Mosaic web app.
|
|
||||||
*
|
|
||||||
* Assumes:
|
|
||||||
* - Next.js web app running on http://localhost:3000
|
|
||||||
* - NestJS gateway running on http://localhost:14242
|
|
||||||
*
|
|
||||||
* Run with: pnpm --filter @mosaicstack/web test:e2e
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './e2e',
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env['CI'],
|
|
||||||
retries: process.env['CI'] ? 2 : 0,
|
|
||||||
workers: process.env['CI'] ? 1 : undefined,
|
|
||||||
reporter: 'html',
|
|
||||||
use: {
|
|
||||||
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
screenshot: 'only-on-failure',
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// Do NOT auto-start the dev server — tests assume it is already running.
|
|
||||||
// webServer is intentionally omitted so tests can run against a live env.
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { GuestGuard } from '@/components/guest-guard';
|
|
||||||
|
|
||||||
export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<GuestGuard>
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg">
|
|
||||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GuestGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { destroySocket, getSocket } from '@/lib/socket';
|
|
||||||
import type { Conversation, Message } from '@/lib/types';
|
|
||||||
import {
|
|
||||||
ConversationSidebar,
|
|
||||||
type ConversationSidebarRef,
|
|
||||||
} from '@/components/chat/conversation-sidebar';
|
|
||||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
|
||||||
import { ChatInput } from '@/components/chat/chat-input';
|
|
||||||
import { StreamingMessage } from '@/components/chat/streaming-message';
|
|
||||||
|
|
||||||
interface ModelInfo {
|
|
||||||
id: string;
|
|
||||||
provider: string;
|
|
||||||
name: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
contextWindow: number;
|
|
||||||
maxTokens: number;
|
|
||||||
inputTypes: ('text' | 'image')[];
|
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
models: ModelInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatPage(): React.ReactElement {
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [streamingText, setStreamingText] = useState('');
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
||||||
const [selectedModelId, setSelectedModelId] = useState('');
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
|
||||||
|
|
||||||
// Track the active conversation ID in a ref so socket event handlers always
|
|
||||||
// see the current value without needing to be re-registered.
|
|
||||||
const activeIdRef = useRef<string | null>(null);
|
|
||||||
activeIdRef.current = activeId;
|
|
||||||
|
|
||||||
// Accumulate streamed text in a ref so agent:end can read the full content
|
|
||||||
// without stale-closure issues.
|
|
||||||
const streamingTextRef = useRef('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
|
|
||||||
if (savedState !== null) {
|
|
||||||
setIsSidebarOpen(savedState === 'true');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
|
|
||||||
}, [isSidebarOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<ProviderInfo[]>('/api/providers')
|
|
||||||
.then((providers) => {
|
|
||||||
const availableModels = providers
|
|
||||||
.filter((provider) => provider.available)
|
|
||||||
.flatMap((provider) => provider.models);
|
|
||||||
setModels(availableModels);
|
|
||||||
setSelectedModelId((current) => current || availableModels[0]?.id || '');
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setModels([]);
|
|
||||||
setSelectedModelId('');
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load messages when active conversation changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeId) {
|
|
||||||
setMessages([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Clear streaming state when switching conversations
|
|
||||||
setIsStreaming(false);
|
|
||||||
setStreamingText('');
|
|
||||||
streamingTextRef.current = '';
|
|
||||||
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
|
||||||
.then(setMessages)
|
|
||||||
.catch(() => {});
|
|
||||||
}, [activeId]);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, [messages, streamingText]);
|
|
||||||
|
|
||||||
// Socket.io setup — connect once for the page lifetime
|
|
||||||
useEffect(() => {
|
|
||||||
const socket = getSocket();
|
|
||||||
|
|
||||||
function onAgentStart(data: { conversationId: string }): void {
|
|
||||||
// Only update state if the event belongs to the currently viewed conversation
|
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
|
||||||
setIsStreaming(true);
|
|
||||||
setStreamingText('');
|
|
||||||
streamingTextRef.current = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAgentText(data: { conversationId: string; text: string }): void {
|
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
|
||||||
streamingTextRef.current += data.text;
|
|
||||||
setStreamingText((prev) => prev + data.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAgentEnd(data: { conversationId: string }): void {
|
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
|
||||||
const finalText = streamingTextRef.current;
|
|
||||||
setIsStreaming(false);
|
|
||||||
setStreamingText('');
|
|
||||||
streamingTextRef.current = '';
|
|
||||||
// Append the completed assistant message to the local message list.
|
|
||||||
// The Pi agent session is in-memory so the assistant response is not
|
|
||||||
// persisted to the DB — we build the local UI state instead.
|
|
||||||
if (finalText) {
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `assistant-${Date.now()}`,
|
|
||||||
conversationId: data.conversationId,
|
|
||||||
role: 'assistant' as const,
|
|
||||||
content: finalText,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
sidebarRef.current?.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onError(data: { error: string; conversationId?: string }): void {
|
|
||||||
setIsStreaming(false);
|
|
||||||
setStreamingText('');
|
|
||||||
streamingTextRef.current = '';
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `error-${Date.now()}`,
|
|
||||||
conversationId: data.conversationId ?? '',
|
|
||||||
role: 'system' as const,
|
|
||||||
content: `Error: ${data.error}`,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('agent:start', onAgentStart);
|
|
||||||
socket.on('agent:text', onAgentText);
|
|
||||||
socket.on('agent:end', onAgentEnd);
|
|
||||||
socket.on('error', onError);
|
|
||||||
|
|
||||||
// Connect if not already connected
|
|
||||||
if (!socket.connected) {
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off('agent:start', onAgentStart);
|
|
||||||
socket.off('agent:text', onAgentText);
|
|
||||||
socket.off('agent:end', onAgentEnd);
|
|
||||||
socket.off('error', onError);
|
|
||||||
// Fully tear down the socket when the chat page unmounts so we get a
|
|
||||||
// fresh authenticated connection next time the page is visited.
|
|
||||||
destroySocket();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNewConversation = useCallback(async (projectId?: string | null) => {
|
|
||||||
const conv = await api<Conversation>('/api/conversations', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { title: 'New conversation', projectId: projectId ?? null },
|
|
||||||
});
|
|
||||||
|
|
||||||
sidebarRef.current?.addConversation({
|
|
||||||
id: conv.id,
|
|
||||||
title: conv.title,
|
|
||||||
projectId: conv.projectId,
|
|
||||||
updatedAt: conv.updatedAt,
|
|
||||||
archived: conv.archived,
|
|
||||||
});
|
|
||||||
|
|
||||||
setActiveId(conv.id);
|
|
||||||
setMessages([]);
|
|
||||||
setIsSidebarOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSend = useCallback(
|
|
||||||
async (content: string, options?: { modelId?: string }) => {
|
|
||||||
let convId = activeId;
|
|
||||||
|
|
||||||
// Auto-create conversation if none selected
|
|
||||||
if (!convId) {
|
|
||||||
const autoTitle = content.slice(0, 60);
|
|
||||||
const conv = await api<Conversation>('/api/conversations', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { title: autoTitle },
|
|
||||||
});
|
|
||||||
sidebarRef.current?.addConversation({
|
|
||||||
id: conv.id,
|
|
||||||
title: conv.title,
|
|
||||||
projectId: conv.projectId,
|
|
||||||
updatedAt: conv.updatedAt,
|
|
||||||
archived: conv.archived,
|
|
||||||
});
|
|
||||||
setActiveId(conv.id);
|
|
||||||
convId = conv.id;
|
|
||||||
} else if (messages.length === 0) {
|
|
||||||
// Auto-title the initial placeholder conversation from the first user message.
|
|
||||||
const autoTitle = content.slice(0, 60);
|
|
||||||
api<Conversation>(`/api/conversations/${convId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { title: autoTitle },
|
|
||||||
})
|
|
||||||
.then(() => sidebarRef.current?.refresh())
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimistic user message in local UI state
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `user-${Date.now()}`,
|
|
||||||
conversationId: convId,
|
|
||||||
role: 'user' as const,
|
|
||||||
content,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Persist the user message to the DB so conversation history is
|
|
||||||
// available when the page is reloaded or a new session starts.
|
|
||||||
api<Message>(`/api/conversations/${convId}/messages`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { role: 'user', content },
|
|
||||||
}).catch(() => {
|
|
||||||
// Non-fatal: the agent can still process the message even if
|
|
||||||
// REST persistence fails.
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send to WebSocket — gateway creates/resumes the agent session and
|
|
||||||
// streams the response back via agent:start / agent:text / agent:end.
|
|
||||||
const socket = getSocket();
|
|
||||||
if (!socket.connected) {
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
socket.emit('message', {
|
|
||||||
conversationId: convId,
|
|
||||||
content,
|
|
||||||
modelId: (options?.modelId ?? selectedModelId) || undefined,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[activeId, messages, selectedModelId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
|
|
||||||
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
|
|
||||||
>
|
|
||||||
<ConversationSidebar
|
|
||||||
ref={sidebarRef}
|
|
||||||
isOpen={isSidebarOpen}
|
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
|
||||||
currentConversationId={activeId}
|
|
||||||
onSelectConversation={(conversationId) => {
|
|
||||||
setActiveId(conversationId);
|
|
||||||
setMessages([]);
|
|
||||||
if (conversationId && window.innerWidth < 768) {
|
|
||||||
setIsSidebarOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onNewConversation={(projectId) => {
|
|
||||||
void handleNewConversation(projectId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-3 border-b px-4 py-3"
|
|
||||||
style={{ borderColor: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsSidebarOpen((open) => !open)}
|
|
||||||
className="rounded-lg border p-2 transition-colors"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
color: 'var(--text)',
|
|
||||||
}}
|
|
||||||
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
|
||||||
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
|
||||||
Mosaic Chat
|
|
||||||
</h1>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
|
||||||
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeId ? (
|
|
||||||
<>
|
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<MessageBubble key={msg.id} message={msg} />
|
|
||||||
))}
|
|
||||||
{isStreaming && <StreamingMessage text={streamingText} />}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
<ChatInput
|
|
||||||
onSend={handleSend}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
models={models}
|
|
||||||
selectedModelId={selectedModelId}
|
|
||||||
onModelChange={setSelectedModelId}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-1 items-center justify-center px-6">
|
|
||||||
<div
|
|
||||||
className="max-w-md rounded-2xl border px-8 py-10 text-center"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
|
|
||||||
Welcome to Mosaic Chat
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
|
|
||||||
Select a conversation or start a new one
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void handleNewConversation();
|
|
||||||
}}
|
|
||||||
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
|
|
||||||
style={{ background: 'var(--primary)' }}
|
|
||||||
>
|
|
||||||
Start new conversation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { AppShell } from '@/components/layout/app-shell';
|
|
||||||
import { AuthGuard } from '@/components/auth-guard';
|
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<AuthGuard>
|
|
||||||
<AppShell>{children}</AppShell>
|
|
||||||
</AuthGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
|
||||||
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
|
||||||
import { PrdViewer } from '@/components/projects/prd-viewer';
|
|
||||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
|
||||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
|
||||||
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
|
||||||
|
|
||||||
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
active: 'bg-success/20 text-success',
|
|
||||||
paused: 'bg-warning/20 text-warning',
|
|
||||||
completed: 'bg-blue-600/20 text-blue-400',
|
|
||||||
archived: 'bg-gray-600/20 text-gray-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TabButtonProps {
|
|
||||||
id: Tab;
|
|
||||||
label: string;
|
|
||||||
activeTab: Tab;
|
|
||||||
onClick: (tab: Tab) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onClick(id)}
|
|
||||||
className={cn(
|
|
||||||
'border-b-2 px-4 py-2 text-sm transition-colors',
|
|
||||||
activeTab === id
|
|
||||||
? 'border-text-primary text-text-primary'
|
|
||||||
: 'border-transparent text-text-muted hover:text-text-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectDetailPage(): React.ReactElement {
|
|
||||||
const params = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const id = typeof params['id'] === 'string' ? params['id'] : '';
|
|
||||||
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
|
||||||
const [missions, setMissions] = useState<Mission[]>([]);
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
|
||||||
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
Promise.all([
|
|
||||||
api<Project>(`/api/projects/${id}`),
|
|
||||||
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
|
||||||
api<Task[]>(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]),
|
|
||||||
])
|
|
||||||
.then(([proj, allMissions, tks]) => {
|
|
||||||
setProject(proj);
|
|
||||||
setMissions(allMissions.filter((m) => m.projectId === id));
|
|
||||||
setTasks(tks);
|
|
||||||
})
|
|
||||||
.catch((err: Error) => {
|
|
||||||
setError(err.message ?? 'Failed to load project');
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleTaskClick = useCallback((task: Task) => {
|
|
||||||
setSelectedTask(task);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseTaskModal = useCallback(() => {
|
|
||||||
setSelectedTask(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="py-16 text-center">
|
|
||||||
<p className="text-sm text-text-muted">Loading project...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !project) {
|
|
||||||
return (
|
|
||||||
<div className="py-16 text-center">
|
|
||||||
<p className="text-sm text-error">{error ?? 'Project not found'}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push('/projects')}
|
|
||||||
className="mt-4 text-sm text-text-muted underline hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
Back to projects
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter);
|
|
||||||
|
|
||||||
const prdContent = getPrdContent(project);
|
|
||||||
const hasPrd = Boolean(prdContent);
|
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string }[] = [
|
|
||||||
{ id: 'overview', label: 'Overview' },
|
|
||||||
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
|
||||||
{ id: 'missions', label: `Missions (${missions.length})` },
|
|
||||||
...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push('/projects')}
|
|
||||||
className="hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
Projects
|
|
||||||
</button>
|
|
||||||
<span>/</span>
|
|
||||||
<span className="text-text-primary">{project.name}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Project header */}
|
|
||||||
<div className="mb-6 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-2 py-0.5 text-xs',
|
|
||||||
statusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{project.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{project.description && (
|
|
||||||
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 text-xs text-text-muted">
|
|
||||||
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
|
||||||
{new Date(project.updatedAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats bar */}
|
|
||||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
||||||
<StatCard label="Tasks" value={String(tasks.length)} />
|
|
||||||
<StatCard
|
|
||||||
label="Done"
|
|
||||||
value={String(tasks.filter((t) => t.status === 'done').length)}
|
|
||||||
valueClass="text-success"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="In Progress"
|
|
||||||
value={String(tasks.filter((t) => t.status === 'in-progress').length)}
|
|
||||||
valueClass="text-blue-400"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Blocked"
|
|
||||||
value={String(tasks.filter((t) => t.status === 'blocked').length)}
|
|
||||||
valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="mb-6 flex gap-0 border-b border-surface-border">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TabButton
|
|
||||||
key={tab.id}
|
|
||||||
id={tab.id}
|
|
||||||
label={tab.label}
|
|
||||||
activeTab={activeTab}
|
|
||||||
onClick={setActiveTab}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
{activeTab === 'overview' && (
|
|
||||||
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'tasks' && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<TaskStatusSummary
|
|
||||||
tasks={tasks}
|
|
||||||
activeFilter={taskFilter}
|
|
||||||
onFilterChange={setTaskFilter}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TaskListView tasks={filteredTasks} onTaskClick={handleTaskClick} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'missions' && <MissionTimeline missions={missions} />}
|
|
||||||
|
|
||||||
{activeTab === 'prd' && prdContent && (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
|
||||||
<PrdViewer content={prdContent} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Task detail modal */}
|
|
||||||
{selectedTask && <TaskDetailModal task={selectedTask} onClose={handleCloseTaskModal} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverviewTabProps {
|
|
||||||
project: Project;
|
|
||||||
missions: Mission[];
|
|
||||||
tasks: Task[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement {
|
|
||||||
const recentTasks = [...tasks]
|
|
||||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
{/* Recent tasks */}
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
|
||||||
{recentTasks.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No tasks yet</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{recentTasks.map((task) => (
|
|
||||||
<TaskSummaryRow key={task.id} task={task} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Mission summary */}
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
|
||||||
{missions.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No missions yet</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<MissionTimeline missions={missions.slice(0, 4)} />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
{project.metadata && Object.keys(project.metadata).length > 0 && (
|
|
||||||
<section className="lg:col-span-2">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<pre className="overflow-x-auto text-xs text-text-muted">
|
|
||||||
{JSON.stringify(project.metadata, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskStatusColors: Record<string, string> = {
|
|
||||||
'not-started': 'bg-gray-600/20 text-gray-300',
|
|
||||||
'in-progress': 'bg-blue-600/20 text-blue-400',
|
|
||||||
blocked: 'bg-error/20 text-error',
|
|
||||||
done: 'bg-success/20 text-success',
|
|
||||||
cancelled: 'bg-gray-600/20 text-gray-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
function TaskSummaryRow({ task }: { task: Task }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2">
|
|
||||||
<span className="truncate text-sm text-text-primary">{task.title}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 rounded-full px-2 py-0.5 text-xs',
|
|
||||||
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{task.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
valueClass,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
valueClass?: string;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
|
|
||||||
<p className="text-xs text-text-muted">{label}</p>
|
|
||||||
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPrdContent(project: Project): string | null {
|
|
||||||
if (!project.metadata) return null;
|
|
||||||
|
|
||||||
const prd = project.metadata['prd'];
|
|
||||||
if (typeof prd === 'string' && prd.trim().length > 0) return prd;
|
|
||||||
|
|
||||||
const prdContent = project.metadata['prdContent'];
|
|
||||||
if (typeof prdContent === 'string' && prdContent.trim().length > 0) return prdContent;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import type { Project } from '@/lib/types';
|
|
||||||
import { ProjectCard } from '@/components/projects/project-card';
|
|
||||||
|
|
||||||
export default function ProjectsPage(): React.ReactElement {
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Project[]>('/api/projects')
|
|
||||||
.then(setProjects)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleProjectClick = useCallback(
|
|
||||||
(project: Project) => {
|
|
||||||
router.push(`/projects/${project.id}`);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
|
||||||
) : projects.length === 0 ? (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
|
||||||
<p className="mt-1 text-sm text-text-muted">
|
|
||||||
Projects will appear here when created via the gateway API
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<ProjectCard key={project.id} project={project} onClick={handleProjectClick} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mission status section */}
|
|
||||||
<MissionStatus />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MissionStatus(): React.ReactElement {
|
|
||||||
const [mission, setMission] = useState<Record<string, unknown> | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Record<string, unknown>>('/api/coord/status')
|
|
||||||
.then(setMission)
|
|
||||||
.catch(() => setMission(null))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mt-8">
|
|
||||||
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-text-muted">Loading mission status...</p>
|
|
||||||
) : !mission ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No active mission detected</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<StatCard label="Mission" value={String(mission['missionId'] ?? 'Unknown')} />
|
|
||||||
<StatCard label="Phase" value={String(mission['currentPhase'] ?? '—')} />
|
|
||||||
<StatCard
|
|
||||||
label="Tasks"
|
|
||||||
value={`${mission['completedTasks'] ?? 0} / ${mission['totalTasks'] ?? 0}`}
|
|
||||||
/>
|
|
||||||
<StatCard label="Status" value={String(mission['status'] ?? '—')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ label, value }: { label: string; value: string }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg bg-surface-elevated p-3">
|
|
||||||
<p className="text-xs text-text-muted">{label}</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
import type { Task } from '@/lib/types';
|
|
||||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
|
||||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
|
||||||
|
|
||||||
type ViewMode = 'list' | 'kanban';
|
|
||||||
|
|
||||||
export default function TasksPage(): React.ReactElement {
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [view, setView] = useState<ViewMode>('kanban');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Task[]>('/api/tasks')
|
|
||||||
.then(setTasks)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTaskClick = useCallback((task: Task) => {
|
|
||||||
// Task detail view will be added in future iteration
|
|
||||||
console.log('Task clicked:', task.id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex rounded-lg border border-surface-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setView('list')}
|
|
||||||
className={cn(
|
|
||||||
'px-3 py-1.5 text-xs transition-colors',
|
|
||||||
view === 'list'
|
|
||||||
? 'bg-surface-elevated text-text-primary'
|
|
||||||
: 'text-text-muted hover:text-text-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setView('kanban')}
|
|
||||||
className={cn(
|
|
||||||
'px-3 py-1.5 text-xs transition-colors',
|
|
||||||
view === 'kanban'
|
|
||||||
? 'bg-surface-elevated text-text-primary'
|
|
||||||
: 'text-text-muted hover:text-text-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Kanban
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
|
||||||
) : view === 'kanban' ? (
|
|
||||||
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
|
|
||||||
) : (
|
|
||||||
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
|
||||||
import { signIn } from '@/lib/auth-client';
|
|
||||||
import { getSsoProvider } from '@/lib/sso-providers';
|
|
||||||
|
|
||||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
|
||||||
const params = useParams<{ provider: string }>();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
|
||||||
const provider = getSsoProvider(providerId);
|
|
||||||
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentProvider = provider;
|
|
||||||
|
|
||||||
if (!currentProvider) {
|
|
||||||
setError('Unknown SSO provider.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentProvider.enabled) {
|
|
||||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeProvider = currentProvider;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
|
||||||
const result = await signIn.oauth2({
|
|
||||||
providerId: activeProvider.id,
|
|
||||||
callbackURL,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cancelled && result?.error) {
|
|
||||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void redirectToProvider();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [callbackURL, provider]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
|
||||||
{provider
|
|
||||||
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
|
||||||
: 'Preparing your sign-in request...'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
|
||||||
<p>{error}</p>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
|
||||||
>
|
|
||||||
Return to login
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
|
||||||
If the redirect does not start automatically, return to the login page and try again.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { Metadata } from 'next';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { ThemeProvider } from '@/providers/theme-provider';
|
|
||||||
import './globals.css';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Mosaic',
|
|
||||||
description: 'Mosaic Stack Dashboard',
|
|
||||||
};
|
|
||||||
|
|
||||||
function themeScript(): string {
|
|
||||||
return `
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
|
||||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
|
||||||
} catch (error) {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<html lang="en" suppressHydrationWarning>
|
|
||||||
<head>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
|
||||||
/>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
export default function HomePage(): never {
|
|
||||||
redirect('/chat');
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
interface AdminRoleGuardProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminRoleGuard({ children }: AdminRoleGuardProps): React.ReactElement | null {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const user = session?.user as
|
|
||||||
| (NonNullable<typeof session>['user'] & { role?: string })
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPending && !session) {
|
|
||||||
router.replace('/login');
|
|
||||||
} else if (!isPending && session && user?.role !== 'admin') {
|
|
||||||
router.replace('/');
|
|
||||||
}
|
|
||||||
}, [isPending, session, user?.role, router]);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session || user?.role !== 'admin') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
interface AuthGuardProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthGuard({ children }: AuthGuardProps): React.ReactElement | null {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPending && !session) {
|
|
||||||
router.replace('/login');
|
|
||||||
}
|
|
||||||
}, [isPending, session, router]);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
interface GuestGuardProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Redirects authenticated users away from auth pages. */
|
|
||||||
export function GuestGuard({ children }: GuestGuardProps): React.ReactElement | null {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPending && session) {
|
|
||||||
router.replace('/chat');
|
|
||||||
}
|
|
||||||
}, [isPending, session, router]);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { signOut, useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
interface AppHeaderProps {
|
|
||||||
conversationTitle?: string | null;
|
|
||||||
isSidebarOpen: boolean;
|
|
||||||
onToggleSidebar: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ThemeMode = 'dark' | 'light';
|
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
|
|
||||||
|
|
||||||
export function AppHeader({
|
|
||||||
conversationTitle,
|
|
||||||
isSidebarOpen,
|
|
||||||
onToggleSidebar,
|
|
||||||
}: AppHeaderProps): React.ReactElement {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const [currentTime, setCurrentTime] = useState('');
|
|
||||||
const [version, setVersion] = useState<string | null>(null);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
const [theme, setTheme] = useState<ThemeMode>('dark');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function updateTime(): void {
|
|
||||||
setCurrentTime(
|
|
||||||
new Date().toLocaleTimeString([], {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTime();
|
|
||||||
const interval = window.setInterval(updateTime, 60_000);
|
|
||||||
return () => window.clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/version.json')
|
|
||||||
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
|
|
||||||
.then((data) => {
|
|
||||||
if (data.version) {
|
|
||||||
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => setVersion(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
||||||
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
|
|
||||||
applyTheme(nextTheme);
|
|
||||||
setTheme(nextTheme);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleThemeToggle = useCallback(() => {
|
|
||||||
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
|
||||||
applyTheme(nextTheme);
|
|
||||||
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
|
||||||
setTheme(nextTheme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const handleSignOut = useCallback(async (): Promise<void> => {
|
|
||||||
await signOut();
|
|
||||||
window.location.href = '/login';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
|
|
||||||
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header
|
|
||||||
className="sticky top-0 z-20 border-b backdrop-blur-xl"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleSidebar}
|
|
||||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
|
|
||||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
|
||||||
aria-label="Toggle conversation sidebar"
|
|
||||||
aria-expanded={isSidebarOpen}
|
|
||||||
>
|
|
||||||
☰
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Link href="/chat" className="flex min-w-0 items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
M
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
|
|
||||||
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
|
|
||||||
<div className="hidden items-center gap-2 md:flex">
|
|
||||||
<span className="relative flex h-2.5 w-2.5">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
|
|
||||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
|
|
||||||
</span>
|
|
||||||
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
|
||||||
Online
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
|
||||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
|
|
||||||
{currentTime || '--:--'}
|
|
||||||
</div>
|
|
||||||
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
|
|
||||||
{conversationTitle?.trim() || 'New Session'}
|
|
||||||
</div>
|
|
||||||
{version ? (
|
|
||||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
|
||||||
v{version}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="hidden items-center gap-2 lg:flex">
|
|
||||||
<ShortcutHint label="⌘/" text="focus" />
|
|
||||||
<ShortcutHint label="⌘K" text="focus" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleThemeToggle}
|
|
||||||
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
|
|
||||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? '☀︎' : '☾'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMenuOpen((prev) => !prev)}
|
|
||||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-2)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
aria-expanded={menuOpen}
|
|
||||||
aria-label="Open user menu"
|
|
||||||
>
|
|
||||||
{session?.user.image ? (
|
|
||||||
<img
|
|
||||||
src={session.user.image}
|
|
||||||
alt={userLabel}
|
|
||||||
className="h-full w-full rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
initials
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{menuOpen ? (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
|
|
||||||
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
|
|
||||||
{session?.user.email ? (
|
|
||||||
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="p-1">
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleSignOut()}
|
|
||||||
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
|
||||||
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
|
|
||||||
<span>{text}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(label: string): string {
|
|
||||||
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
|
|
||||||
if (words.length === 0) return 'M';
|
|
||||||
return words.map((word) => word.charAt(0).toUpperCase()).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTheme(theme: ThemeMode): void {
|
|
||||||
const root = document.documentElement;
|
|
||||||
if (theme === 'light') {
|
|
||||||
root.setAttribute('data-theme', 'light');
|
|
||||||
root.classList.remove('dark');
|
|
||||||
} else {
|
|
||||||
root.removeAttribute('data-theme');
|
|
||||||
root.classList.add('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { io, type Socket } from 'socket.io-client';
|
|
||||||
|
|
||||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
|
||||||
|
|
||||||
let socket: Socket | null = null;
|
|
||||||
|
|
||||||
export function getSocket(): Socket {
|
|
||||||
if (!socket) {
|
|
||||||
socket = io(`${GATEWAY_URL}/chat`, {
|
|
||||||
withCredentials: true,
|
|
||||||
autoConnect: false,
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset singleton reference when socket is fully closed so the next
|
|
||||||
// getSocket() call creates a fresh instance instead of returning a
|
|
||||||
// closed/dead socket.
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
socket = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return socket;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tear down the singleton socket and reset the reference. */
|
|
||||||
export function destroySocket(): void {
|
|
||||||
if (socket) {
|
|
||||||
socket.offAny();
|
|
||||||
socket.disconnect();
|
|
||||||
socket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
|
||||||
|
|
||||||
describe('sso-providers', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the enabled providers in login button order', () => {
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
|
||||||
|
|
||||||
expect(getEnabledSsoProviders()).toEqual([
|
|
||||||
{
|
|
||||||
id: 'workos',
|
|
||||||
buttonLabel: 'Continue with WorkOS',
|
|
||||||
description: 'Enterprise SSO via WorkOS',
|
|
||||||
enabled: true,
|
|
||||||
href: '/auth/provider/workos',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
enabled: true,
|
|
||||||
href: '/auth/provider/keycloak',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
|
||||||
|
|
||||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
|
||||||
expect(getSsoProvider('keycloak')).toEqual({
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
enabled: false,
|
|
||||||
href: '/auth/provider/keycloak',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for unknown providers', () => {
|
|
||||||
expect(getSsoProvider('authentik')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
export type SsoProviderId = 'workos' | 'keycloak';
|
|
||||||
|
|
||||||
export interface SsoProvider {
|
|
||||||
id: SsoProviderId;
|
|
||||||
buttonLabel: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
|
||||||
workos: {
|
|
||||||
id: 'workos',
|
|
||||||
buttonLabel: 'Continue with WorkOS',
|
|
||||||
description: 'Enterprise SSO via WorkOS',
|
|
||||||
},
|
|
||||||
keycloak: {
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
|
||||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
|
||||||
.map((providerId) => getSsoProvider(providerId))
|
|
||||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
|
||||||
if (!isSsoProviderId(providerId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...PROVIDER_METADATA[providerId],
|
|
||||||
enabled: isSsoProviderEnabled(providerId),
|
|
||||||
href: `/auth/provider/${providerId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
|
||||||
return value === 'workos' || value === 'keycloak';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
|
||||||
switch (providerId) {
|
|
||||||
case 'workos':
|
|
||||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
|
||||||
case 'keycloak':
|
|
||||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
exclude: ['e2e/**', 'node_modules/**'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
services:
|
||||||
|
mosaic-agent:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Containerfile
|
||||||
|
image: ${MOSAIC_IMAGE_TAG:?MOSAIC_IMAGE_TAG must be set by scripts/load_release (run via scripts/*.sh)}
|
||||||
|
user: "1000:1000"
|
||||||
|
environment:
|
||||||
|
# Resolved from config.json by scripts/common.sh (load_config).
|
||||||
|
# Required: compose fails fast when the launcher did not supply them.
|
||||||
|
PI_PROVIDER: ${MOSAIC_PROVIDER:?MOSAIC_PROVIDER must be set by scripts/load_config (run via scripts/*.sh)}
|
||||||
|
PI_MODEL: ${MOSAIC_MODEL:?MOSAIC_MODEL must be set by scripts/load_config (run via scripts/*.sh)}
|
||||||
|
# Adapter selection (resolved from config execution.adapter; default pi)
|
||||||
|
MOSAIC_ADAPTER: ${MOSAIC_ADAPTER:-pi}
|
||||||
|
# Mission directives injection point (set by the task runner when the
|
||||||
|
# task references a mission; container path of the run snapshot)
|
||||||
|
MOSAIC_MISSION_FILE: ${MOSAIC_MISSION_FILE:-}
|
||||||
|
# Workspace + capabilities (set by the task runner; M5)
|
||||||
|
MOSAIC_WORKSPACE: ${MOSAIC_WORKSPACE:-}
|
||||||
|
MOSAIC_TOOLS: ${MOSAIC_TOOLS:-}
|
||||||
|
# Persistent named session dir + optional fork source (M6/M11)
|
||||||
|
MOSAIC_SESSION_DIR: ${MOSAIC_SESSION_DIR:-}
|
||||||
|
MOSAIC_SESSION_FORK: ${MOSAIC_SESSION_FORK:-}
|
||||||
|
# Interactive TUI mode + agent identity (M13, set by scripts/agent.sh)
|
||||||
|
MOSAIC_INTERACTIVE: ${MOSAIC_INTERACTIVE:-}
|
||||||
|
MOSAIC_AGENT_NAME: ${MOSAIC_AGENT_NAME:-}
|
||||||
|
MOSAIC_AGENT_ROLE: ${MOSAIC_AGENT_ROLE:-}
|
||||||
|
MOSAIC_AGENT_SOUL_FILE: ${MOSAIC_AGENT_SOUL_FILE:-}
|
||||||
|
# Skill dirs explicitly provided to the seat (M17)
|
||||||
|
MOSAIC_SKILLS: ${MOSAIC_SKILLS:-}
|
||||||
|
# mock adapter only: verbatim response for deterministic seam tests
|
||||||
|
MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-}
|
||||||
|
# Documented container auth alternative: provider API key via
|
||||||
|
# runtime environment variable. Empty by default; when empty Pi
|
||||||
|
# falls back to the read-only mounted auth.json credential file.
|
||||||
|
ZAI_API_KEY: ${ZAI_API_KEY:-}
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
|
volumes:
|
||||||
|
# Configured runtime state root (from config.json dataRoot).
|
||||||
|
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}:/var/lib/mosaic
|
||||||
|
# Runtime credential only: pi auth file mounted READ-ONLY.
|
||||||
|
# Never copied into the image.
|
||||||
|
- ${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}:/home/node/.pi/agent/auth.json:ro
|
||||||
|
# Headless runs: the request is passed as command args by the launchers
|
||||||
|
# (run-task.sh) or defaults inside run-agent.sh (hello/verify). Never a
|
||||||
|
# fixed command here - interactive runs (scripts/agent.sh) need no args.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# POC constitution
|
||||||
|
|
||||||
|
Never print credentials, tokens, or authentication files.
|
||||||
|
|
||||||
|
Follow the loaded system instructions before the user request.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# POC identity
|
||||||
|
|
||||||
|
Your name is mosaic-poc-agent.
|
||||||
|
|
||||||
|
Your startup marker is MOSAIC_HELLO_OK.
|
||||||
|
|
||||||
|
When asked for your startup marker, return only the marker.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# POC standards
|
||||||
|
|
||||||
|
Answer startup verification requests with only the requested value.
|
||||||
|
Do not add explanation or formatting.
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg17
|
|
||||||
ports:
|
|
||||||
- '${PG_HOST_PORT:-5433}:5432'
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: mosaic
|
|
||||||
POSTGRES_PASSWORD: mosaic
|
|
||||||
POSTGRES_DB: mosaic
|
|
||||||
volumes:
|
|
||||||
- pg_data:/var/lib/postgresql/data
|
|
||||||
- ./infra/pg-init:/docker-entrypoint-initdb.d:ro
|
|
||||||
healthcheck:
|
|
||||||
test: ['CMD-SHELL', 'pg_isready -U mosaic']
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
valkey:
|
|
||||||
image: valkey/valkey:8-alpine
|
|
||||||
ports:
|
|
||||||
- '${VALKEY_HOST_PORT:-6380}:6379'
|
|
||||||
volumes:
|
|
||||||
- valkey_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ['CMD', 'valkey-cli', 'ping']
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
otel-collector:
|
|
||||||
image: otel/opentelemetry-collector-contrib:0.100.0
|
|
||||||
ports:
|
|
||||||
- '4317:4317' # OTLP gRPC
|
|
||||||
- '4318:4318' # OTLP HTTP
|
|
||||||
volumes:
|
|
||||||
- ./infra/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
|
|
||||||
depends_on:
|
|
||||||
jaeger:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
jaeger:
|
|
||||||
image: jaegertracing/jaeger:2.6.0
|
|
||||||
ports:
|
|
||||||
- '16686:16686' # Jaeger UI
|
|
||||||
- '4319:4317' # Jaeger OTLP gRPC (internal, collector forwards here)
|
|
||||||
environment:
|
|
||||||
COLLECTOR_OTLP_ENABLED: 'true'
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pg_data:
|
|
||||||
valkey_data:
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
FROM node:22-alpine AS base
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
# Copy workspace manifests first for layer-cached install
|
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|
||||||
COPY apps/gateway/package.json ./apps/gateway/
|
|
||||||
COPY packages/ ./packages/
|
|
||||||
COPY plugins/ ./plugins/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
COPY . .
|
|
||||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
|
||||||
RUN pnpm turbo run build --filter @mosaicstack/gateway...
|
|
||||||
# Produce a self-contained deploy artifact: flat node_modules, no pnpm symlinks
|
|
||||||
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
|
|
||||||
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
|
||||||
|
|
||||||
FROM base AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
|
||||||
COPY --from=builder /deploy/node_modules ./node_modules
|
|
||||||
COPY --from=builder /deploy/package.json ./package.json
|
|
||||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
|
||||||
# copy from builder explicitly as belt-and-suspenders
|
|
||||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
|
||||||
EXPOSE 4000
|
|
||||||
CMD ["node", "dist/main.js"]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
FROM node:22-alpine AS base
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|
||||||
COPY apps/web/package.json ./apps/web/
|
|
||||||
COPY packages/ ./packages/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm --filter @mosaic/web build
|
|
||||||
|
|
||||||
FROM base AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
|
||||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
|
||||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["node", "apps/web/server.js"]
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# KICKSTART — paste this into a fresh agent session
|
||||||
|
|
||||||
|
```text
|
||||||
|
You are the conductor of the Mosaic Stack rebuild in this repository
|
||||||
|
(mosaicstack/stack-v2 dev-test). Re-orient in this order:
|
||||||
|
|
||||||
|
1. AGENTS.md — canon, invariants, session protocol
|
||||||
|
2. docs/plans/CURRENT.md — the single next action
|
||||||
|
3. docs/SESSIONS.md — who worked here and what shipped
|
||||||
|
4. docs/plans/CONDUCTOR.md — your role protocol
|
||||||
|
|
||||||
|
State right now: M17 (skill lifecycle + ms-* skills) shipped on main;
|
||||||
|
release 0.0.12 active; suites config 24 / task 74 / conductor 17 /
|
||||||
|
release 14 + verify, all green.
|
||||||
|
|
||||||
|
A live pi collaborator (glm-5.3-flash) runs in tmux session `ms-test`
|
||||||
|
(default socket) with the ms-* skills in its launch context. Your next
|
||||||
|
action: calibrate the conductor loop with it. Message via
|
||||||
|
tools/tmux/agent-send.sh (never raw send-keys), protocol in
|
||||||
|
skills/ms-communications/SKILL.md. Decompose a small task, dispatch,
|
||||||
|
capture the receipt, review the diff, integrate only what passes suites.
|
||||||
|
|
||||||
|
Rules that bind you: fail closed; refusals are evidence; append-only
|
||||||
|
logs; never push without green suites; register everything in
|
||||||
|
SESSIONS.md; never guess — verify.
|
||||||
|
```
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Session registry — append-only
|
||||||
|
|
||||||
|
Every agent session (assistant, worker-cycle conductor, or owner-directed
|
||||||
|
automation) that works in this repository registers one line here. Entries
|
||||||
|
are never rewritten or removed; corrections are new entries.
|
||||||
|
|
||||||
|
| Date (UTC) | Actor | Scope | Outcome / artifacts |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2026-09-03 | assistant (conductor + worker) | POC through M12: containerized pi proof, config layer, missions/tasks, release model, adapter seam, workspaces/capabilities, named sessions, retention, session forking, conductor auto-apply, roles/ convention | 13 tags; suites 24/58/14 + 17 conductor + verify green; releases 0.0.1–0.0.7; issues #1–#34 closed |
|
||||||
|
| 2026-09-03 | assistant (conductor) | User layer: profile updates (pets, family), ms-user skill review/revision (confirmation rules merged, propose-not-apply, missing-file flow, privacy scope, dispatch = all of user/, rule 9 scratch-file constraint), USER.md.bak removed | skills/ms-user/SKILL.md rewritten; ~/.mosaic-dev/user/USER.md updated (Family, Pets); USER.md.bak deleted |
|
||||||
|
| 2026-09-03 | assistant | ms-communications skill: inter-agent messaging protocol consolidated from tools/tmux/README.md and agent-send.sh (channel, preamble grammar, flip-on-reply, triage classes, etiquette, receiving protocol, delivery mechanics) | skills/ms-communications/SKILL.md created; unslop-check clean |
|
||||||
|
| 2026-09-03 | assistant (conductor) + ms-test collaborator (worker, glm-5.3-flash) | Conductor-loop calibration (#43): decompose → dispatch via agent-send.sh → receipt → line-by-line diff review (claims verified vs tool source) → suite-gated integration; CURRENT.md staleness corrected (M16/M17 late-logged, next action → M18) | docs/TOOLS.md tools/ section + suite-count fix; issue #43 closed; suites 24/74/14/17 + verify green |
|
||||||
|
| 2026-09-03 | owner + assistant (conductor) + ms-test collaborator | Skill revisions adjudicated (#44): ms-communications integrated as-authored (owner preamble restructure + collaborator delivery-discipline hunks); ms-conductor collaborator redraft integrated with conductor remediation (step 3 refusal-vs-outage distinction; preserves owner's outage-dispatch intent inside fail-closed canon); TOOLS.md gains release.sh ensure row | skills/ms-communications/SKILL.md, skills/ms-conductor/SKILL.md, docs/TOOLS.md; suites 24/74/14/17 + verify green; unslop clean ×3 |
|
||||||
|
| 2026-09-03 | assistant (conductor) | M18 seat-role progressive capability restriction (#45): roles/<role>.json contracts (strict schema, name-filename binding, network declared), mosaic-task.mjs resolve-role, agent.sh ceiling intersection with fail-closed refusals, roles/researcher.json shipped, 14 suite cases (task 74 → 88) | scripts/mosaic-task.mjs, scripts/agent.sh, scripts/test-task.sh, roles/researcher.json, docs; suites 24/88/14/17 + verify green |
|
||||||
|
| 2026-09-03 | owner (decision + live verification) + assistant (conductor) | M18 live verification + follow-up (#46): owner confirmed narrowing/refusal/tool-free live; seatless launch under AGENTS_DIR override discovered and made fail-closed (exit 4); task suite 88 → 90 | scripts/agent.sh, scripts/test-task.sh, docs/TOOLS.md; suites 24/90/14/17 + verify green |
|
||||||
|
| 2026-09-03 | assistant (conductor) | M19 harness auth tooling (#47): pi auth investigation (native provider stacking, no native multi-account), scripts/auth.sh status/accounts (never prints credential material), agent.sh --auth per-launch injection via PI_AUTH_FILE, test-auth.sh suite (13 cases incl. secret-never-printed assertions) | scripts/auth.sh, scripts/agent.sh, scripts/test-auth.sh, docs/TOOLS.md, AGENTS.md; suites 24/90/14/17/13 + verify green |
|
||||||
|
| 2026-09-03 | owner (direction) + assistant (conductor) | M19 correction (#48): mosaic-managed auth moved from ~/.pi to the data root (auth/<account>.json, 0600 enforced); ~/.pi read-only to the stack as a ROADMAP standing decision; auth.sh config-driven; test-auth 15 cases | scripts/auth.sh, scripts/agent.sh, scripts/test-auth.sh, docs/TOOLS.md, docs/plans/ROADMAP.md, README.md; suites 24/15/90/14/17 + verify green |
|
||||||
|
| 2026-09-03 | owner (requirements) + assistant (conductor/spec author) | Harness/provider/auth registry specification (#49): agent.json harness declaration, centralized providers/accounts/settings profiles, audited runtime selection, per-seat auth/models materialization, centralized OAuth lifecycle, local/remote Ollama, target mosaic CLI | docs/plans/2026-09-03_auth-provider-harness-registry.md; implementation blocked pending ten-gate review; unslop clean |
|
||||||
|
| 2026-09-03 | ms-test (independent reviewer, zai/glm-5.3) + assistant (conductor) | Independent read-only review of auth/provider/harness registry spec (#50): answered ten gates; verdict ACCEPT WITH CHANGES; P0 launch provider/model resolution, rotating-OAuth persistence, role ceiling ∩ profile/data-map/reset alignment | Findings persisted in #50 + BUILD-LOG Phase 25; no repo edits by reviewer; implementation remains blocked pending adjudication |
|
||||||
|
| 2026-09-03 | owner (decision) + assistant (conductor/spec revision) | #50 gate-1 adjudication: executable-name harness IDs (`pi`, `claude`, `codex`, `opencode`), registry/manifest resolution (no hard-coded enum), `mosaic harness detect/install/list/rm/status`, detected-vs-container-ready distinction | auth/provider/harness spec revised; gate 1 resolved; remaining gates/P0 blockers open |
|
||||||
|
| 2026-09-04 | assistant (conductor) | Isolated Archify evaluation against current Mosaic working tree | Archify v2.17.0-dev.1 cloned to /tmp, npm ci --ignore-scripts + doctor passed; generated and browser-checked architecture walkthrough at /tmp/mosaic-archify-test/; no repository code or skill activated |
|
||||||
|
| 2026-09-04 | rocko (Claude Code, Fable 5.1; examine/evaluate/guide) + Jason (lead) | Archify lane established at ~/.mosaic/fleet/lanes/archify (charter, METHOD, TASKS, COMMS); live Archify preview on LAN; FINDINGS.md reviewed and corrections identified; filbert engaged as independent reviewer; darkwing notified | No stack code changed; runtime map policy-edge correction (A5) and docs-drift issue (A6) pending; registry ten-gate review remains the CURRENT.md action |
|
||||||
|
| 2026-09-05 | rocko (Claude Code, Fable 5.1; archify lane) + Jason (lead) | skills/ms-archify/SKILL.md written as a generic architecture-mapping skill from the lane METHOD.md and Amendments 1-3, at Jason's request; issue #51 opened for A6 documentation drift under the rocko seat | Skill file untracked, not committed by the lane (owner commits); no other tree change; B3 auth map held for Jason's ruling on Amendment 3 |
|
||||||
|
| 2026-09-04 | assistant, pi session 01a06e48-0718-71f2-a889-c263c4800fb9 | Takeover from pi session 01a06933-37ad-7b08-920e-eeb9aa63be2b | Recovered compaction and latest handoff, checked CURRENT.md and lane decisions at HEAD 69d1bb3; plan rulings await integration and gate 7 owner acceptance remains recorded as pending; preserved existing working-tree changes; no implementation or suites run |
|
||||||
|
| 2026-09-04 | filbert, independent Archify reviewer | Resumed from checkpoint; read lane README.md, METHOD.md, TASKS.md and COMMS.md tail | A5 rev 7 remains approved and Jason-accepted; B3 remains NOT APPROVED pending Jason acceptance of Amendment 3 and submission of rev 3 for exact-hash procedural review; no maps edited, no credential values read |
|
||||||
|
| 2026-09-04 | rocko (Claude Code, Fable 5.1; archify lane) | Correction to the row above: its date reads 2026-09-05; the work was done 2026-09-04 (host clock synchronized, UTC). Content unchanged. | Correction row, append-only. |
|
||||||
|
| 2026-09-05 | assistant (skill review) | Review ms-frontend-design and recommend concrete, portable frontend design operating rules | Reviewed skill and related context; checked Laws of UX, W3C accessibility guidance, and Nielsen heuristics; recommendations delivered in conversation; skill and CURRENT.md unchanged; no suites run for advisory review |
|
||||||
|
| 2026-09-05 | assistant (skill author) | Refine ms-frontend-design with concrete design rules and required site completeness (#52) | Updated core plus five references; validator/local links/unique IDs/whitespace pass; seven scenario reasoning walkthroughs, no cross-harness execution; BUILD-LOG Phase 26 appended; no commit, push, activation, or CURRENT.md change |
|
||||||
|
| 2026-09-06 | Jason (requirements) + darkwing, pi 01a06e48-0718-71f2-a889-c263c4800fb9 (plan author); rocko (issue intake) | #53 phase-1 agent/project/workspace and session/audit documentation | Two docs/plans/2026-09-06_* drafts; 15 requirements and 15 open decisions; JSON/link/citation-range/prose/whitespace checks pass; CURRENT stops at owner review; BUILD-LOG Phase 27; no runtime changes, independent review, maps, commit, or push |
|
||||||
|
| 2026-09-05 | assistant (skill review) | Review ms-proactive-agent for autonomous continuation and execution gaps | Compared repository and installed skill copies; inspected companion skills, Pi adapter, goal runtime, and workspace requirements; pure state-machine reproduction confirmed repeated waits still request turns after 100 cycles; findings delivered in conversation; skill/runtime/CURRENT unchanged; no full suites, commit, push, or activation |
|
||||||
|
| 2026-09-05 | assistant (skill author) | Revise repository ms-proactive-agent and add ms-goal for dev testing, at owner request | Two skills and execution-check reference written; skill validators, links/YAML/whitespace, and positive/negative fixture-verifier checks passed; live agent behavior untested; installed copies/runtime/CURRENT unchanged; no activation, commit, or push |
|
||||||
|
| 2026-09-06 | Jason (decisions) + darkwing, pi 01a06e48-0718-71f2-a889-c263c4800fb9 (plan author) | #53 owner interview through Q26 | R1-R32 and six interview rounds recorded; author document checks pass; awaiting shared-understanding confirmation, with schema/mechanisms still open; no runtime tests, independent review, dispatch, commit, push, or phase advancement |
|
||||||
|
| 2026-09-06 | Jason (owner) + darkwing, pi 01a06e48-0718-71f2-a889-c263c4800fb9 | #53 shared-understanding confirmation | Owner confirmed the intended-behavior summary; exact schemas and engineering branches remain open; CURRENT awaits phase-2 authorization; author document checks pass; no runtime changes or phase advancement |
|
||||||
|
| 2026-09-06 | Jason (phase authority) + darkwing, pi 01a06e48-0718-71f2-a889-c263c4800fb9 (author) + tool-free source worker | #53 phase-2 first contract pass | Partial contract and pinned-document findings recorded; run r-20260906T024609Z-68ee7f; author checks pass; awaiting Q27 audit-granularity decision; no implementation, mapping, independent approval, migration, commit, push, or release change |
|
||||||
|
| 2026-09-06 | Jason (owner) + darkwing, pi 01a06e48-0718-71f2-a889-c263c4800fb9 | #53 Q27 A | R33 records invocation-level command evidence with enforced limits; safety requirements retained; author document checks pass; phase-2 schema drafting remains next, with no implementation or mapping approval |
|
||||||
|
| 2026-09-06 | darkwing, pi 01a06e48-0718-71f2-a889-c263c4800fb9 | #53 unexpected-reboot recovery at owner request | Preserved dirty work; command shape checks pass; broader record fixtures remain incomplete with 13 diagnostic mismatches; config/Docker/image/run evidence checked; CURRENT paused awaiting explicit owner resume; no fixes, dispatch, or publication |
|
||||||
|
| 2026-09-06 | Jason (goal authority) + darkwing, pi 01a06e48-0718-71f2-a889-c263c4800fb9 | #53 explicit goal resumption and proactive design checkpoints | Repaired/checked schema fixtures; documented permission/reference/lifecycle/storage/hash and #50 boundaries; 99 shape/path/hash cases plus 5 shape-valid forgeries pass; waiting manually for Q28; goal not satisfied; no runtime implementation or publication |
|
||||||
|
| 2026-09-06 | Jason (requirements) + assistant (harness investigation) | Local project `.pi` test plan for loading `~/.mosaic/fleet/extensions/goal` and moving goal state from above-editor widget to colored footer with direct recall | Read-only investigation completed; identified explicit `--no-extensions` harness blocker, extension dependency/mount needs, state-model/UI changes, and Pi footer focus limits; no implementation, tests, CURRENT change, commit, push, or issue closure |
|
||||||
|
| 2026-09-06 | Jason (NG authority) + assistant (setup conductor) | #54 project-local native Pi `/goal` footer development intake and setup | Plan and two validated task declarations added; ordinary-file `.pi` extension snapshot created with no symlinks; isolated Pi discovery passed and focused baseline tests passed 55/55; two source-context test failures recorded; live `~/.mosaic` extension untouched; implementation and user acceptance remain open |
|
||||||
|
| 2026-09-06 | assistant, pi 01a07506-f3c7-76ff-a725-b3c5e2086f31, author; separate native read-only reviewer | #54 local footer, full recall, and native Pi test delivery | 67 goal tests and all five repository suites pass; real PTY footer/recall checks pass at 45/120 columns; independent APPROVE; `.pi/goal-dev.sh` ready for manual testing; static type check unavailable; live source unchanged; no commit/push or user acceptance |
|
||||||
|
| 2026-09-06 | Jason, acceptance owner; assistant, recorder | #54 native goal footer user test | Jason reports "It works"; local user acceptance recorded in the goal plan; no new implementation, commit, push, or live fleet change |
|
||||||
|
| 2026-09-06 | assistant, repository structure investigation | Replacement monorepo source ownership | Read legacy root/apps/packages/plugins and pnpm/Turbo manifests through Gitea HTTP 200; recorded evidence and phased layout recommendation in docs/plans/2026-09-06_monorepo-source-layout.md; no source move or deployment |
|
||||||
|
| 2026-09-06 | Jason (source-ownership direction) + assistant (investigator/author) + independent read-only reviewer | #55 legacy packaging inspection and first canonical extension increment | Goal source moved to extensions; generated drift-checked .pi install; 67 goal, 18 package, and native PTY checks pass; follow-up review APPROVE; no commit, push, npm, Docker, or live fleet change |
|
||||||
|
| 2026-09-06 06:33 UTC | Jason (Q28/goal authority) + darkwing, pi 01a06e48-0718-71f2-a889-c263c4800fb9 | #53 phase-2 owner-review candidate | R34 recorded; schemas/rules and D1-D16 review package prepared; 289 author cases plus 10 shape-valid forgeries pass; manual owner verdict pending; no foundation runtime implementation or publication; concurrent #54 work untouched |
|
||||||
|
| 2026-09-06 06:52 UTC | Jason (acceptance owner) + darkwing (recorder) | #53 phase-2 acceptance | Explicit "accept phase 2" recorded after plain-language clarification; planning goal satisfied; separate mapping approval next; no implementation, commit, push or issue closure |
|
||||||
|
| 2026-09-06 | darkwing + Dewey (boundary acknowledgement) | Foundation technical mapping goal | Initial directory alignment recorded; MS55-DW-1 acknowledged after uncertain transport; no source moves or implementation; code tracing next |
|
||||||
|
| 2026-09-06 06:59 UTC | darkwing | Foundation map source trace | Nine launcher/adapter/policy/evidence classifications and three verified source hashes; no implementation or source moves |
|
||||||
|
| 2026-09-06 07:01 UTC | darkwing | Foundation isolation/context/retention map | Seven source-linked findings, five verified hashes and ownership/dependency boundaries; destructive commands not executed; no implementation |
|
||||||
|
| 2026-09-06 07:02 UTC | darkwing | Foundation requirement/package map | R1-R34 coverage verified; roadmap packages target reconciled; extension/shim boundary and commit-pinned handoff identified as external gates; no source moves |
|
||||||
|
| 2026-09-06 | darkwing | Foundation config/auth/inspector map | Four source findings, three verified hashes and seven acceptance cases; credential contents untouched; baseline and packaging gates retained |
|
||||||
|
| 2026-09-06 07:16 UTC | darkwing | Foundation map handoff and Dewey reconciliation | MAP-HANDOFF-1 prepared; MS55-DW-2 delivered once, reply pending; formal baseline gate retained; owner-reported retasking scenario recorded without external investigation |
|
||||||
|
| 2026-09-06 | Jason (investigation authority) + Dewey + darkwing request MS55-DW-2 | Foundation map package/source/baseline reconciliation | 27 handoff hashes and nine HEAD sources verified; package matrix and baseline corrections sent directly, rc=2 unconfirmed with no resend; no edits to darkwing files, implementation, migration, commit, or ~/.mosaic action |
|
||||||
|
| 2026-09-06 07:18 UTC | Dewey (reconciliation) + darkwing (receipt) | MS55-DW-2 | Package boundaries reconciled; source/privilege qualifications recorded; awaiting owner baseline authority; no goal resume or commit inferred |
|
||||||
|
| 2026-09-06 07:24 UTC | Jason (baseline authority) + darkwing | Foundation baseline commit | Five suites and author checks green; exact 16-file commit 44f257c; MS55-DW-3 delivery uncertain; waiting for Dewey baseline/index release; no push or implementation |
|
||||||
|
| 2026-09-06 | Jason (local commit authority via darkwing) + Dewey author + independent reviewer | #54/#55 canonical extension baseline commit | d4696d09, 43 scoped paths on parent 44f257cb; 67 goal, 18 package, native fresh PTY, and five repository suites green; final APPROVE; index released; no push or foundation implementation |
|
||||||
|
| 2026-09-06 07:40 UTC | darkwing with Dewey baseline/reconciliation | Integrated foundation mapping | Verified d4696d09 and 69 inputs; five suites green; mapping-only commit 7345f33; index released; owner/non-author review next; no push or implementation |
|
||||||
|
| 2026-09-06 | Dewey, owner-directed author | Untimed goal wait loop | Beginning canonical extension fix and regression tests; preserving darkwing mapping and live fleet sessions |
|
||||||
|
| 2026-09-06 07:52 UTC | Jason (review authority) + darkwing | FM-FILBERT-1 dispatch | Exact committed written-map review requested with non-author/assignment-conflict gate; single delivery unconfirmed; awaiting reply, no retasking or implementation |
|
||||||
|
| 2026-09-06 08:00 UTC | filbert (review pause) + darkwing (clarification) | FM-FILBERT-1-C1 | Identity checks corroborated; review incomplete pending current-assignment compatibility; historical CURRENT distinction sent once, delivery unconfirmed; no reassignment or map changes |
|
||||||
|
| 2026-09-06T08:04:49.508743+00:00 | Dewey | #56 quiet goal waits, local fix and authorized three-copy deployment | Independent reviews approved; canonical 71, legacy 67, package 18 and all five repo suites green; six installed native canaries and sixteen links verified; backups retained, existing sessions untouched, awaiting owner reload/acceptance |
|
||||||
|
| 2026-09-06 08:06 UTC | filbert (independent review) + darkwing (verified receipt) | FM-FILBERT-1 complete | Written map APPROVED at exact hashes; verdict 6b08c6fa verified; earlier admission blocker withdrawn; owner acceptance pending, no runtime/renderer acceptance or implementation |
|
||||||
|
| 2026-09-06 08:07 UTC | Jason (owner acceptance) + darkwing (receipt) | Reviewed technical map accepted | Mapping 7345f33 accepted as planning; exact map/handoff/verdict unchanged; inspector charter awaits authorization; no implementation or publication |
|
||||||
|
| 2026-09-06 08:11 UTC | darkwing | Inspector charter under #53 | Draft with nine acceptance groups; Rocko feasibility delivered, Filbert review availability unconfirmed; scoped planning only, no implementation or retasking |
|
||||||
|
| 2026-09-06 08:11 UTC | filbert (availability) + darkwing (receipt) | FI-FILBERT-1 | Available without assignment conflict or draft co-authorship; review waits for frozen charter; initial delivery uncertainty resolved |
|
||||||
|
| 2026-09-06 08:25 UTC | rocko (feasibility) + darkwing (source reconciliation) | FI-ROCKO-2 | Full note and hashes checked; nine correction groups sent/delivered; corrected note pending before freeze; no implementation |
|
||||||
|
| 2026-09-06 | Dewey | Owner-requested Resume goal discovery conflict | Native reproduction and bounded launcher repair underway; no existing sessions or other seats changed |
|
||||||
|
| 2026-09-06 08:29:51 UTC | Dewey | #57 Resume-only launch conflict | Reviewed launcher repair deployed; native loader/CLI and 71 goal tests pass; guards/state location retained, no sessions interrupted; awaiting user retry |
|
||||||
|
| 2026-09-06 08:34 UTC | Dewey | #57 owner correction: shared NG selection | Reviewed, deployed and native-verified; guards retained, no goal state writes; ready for normal Resume restart |
|
||||||
|
| 2026-09-06 08:42 UTC | darkwing with rocko feasibility | FI-FILBERT-2 charter freeze | r2 verified; V1-V3 and Node constants measured; charter cbd0487a frozen; exact-hash review requested, delivery unconfirmed; no implementation |
|
||||||
|
| 2026-09-06 | Dewey | Fleet NG goal ownership and scoped commit, owner-authorized | Inventory complete; alias/native regression and reviewed deployment next; Resume UX accepted |
|
||||||
|
| 2026-09-06 08:58 UTC | filbert (NOT APPROVED) + darkwing (revision) | FI-FILBERT-3 | Candidate 2/verdict preserved; five findings addressed in candidate 3, exact hash frozen; re-review requested, delivery unconfirmed; no implementation |
|
||||||
|
| 2026-09-06 09:03 UTC | filbert (independent approval) + darkwing (verified receipt) | FI-FILBERT-3 complete | Charter approved at exact hashes; five findings closed; awaiting owner build authorization; index left with Dewey, no implementation |
|
||||||
|
| 2026-09-06 09:02 UTC | Dewey | #58 fleet shared NG ownership | Reviewed transaction deployed, 55 actual loader combinations and all suites pass; Resume UX accepted, fleet/quiet acceptance pending; scoped commit next, shared logs left unstaged due mixed ownership |
|
||||||
|
| 2026-09-06 09:10 UTC | Dewey | #56/#57/#58 local integration | 9a5fbdb committed and post-commit verified; index released, no push; #57 closed, fleet/quiet user acceptance pending |
|
||||||
|
| 2026-09-06 17:00 UTC | Jason (build authority) + darkwing | FI-ROCKO-3 preparation | Exact charter verified; unclaimed implementation paths checked; scoped sole-writer request prepared; index left with Dewey |
|
||||||
|
| 2026-09-06 17:02 UTC | darkwing | FI-ROCKO-3 dispatched | Build request delivered; Filbert availability unconfirmed; acceptance-linked code-review/demo gates prepared; build and independent review pending |
|
||||||
|
| 2026-09-06 17:05 UTC | rocko (admission) + darkwing (receipt) | FI-ROCKO-3 in progress | Compatible, pinned inputs checked and integration HEAD 9a5fbdb reported; scoped implementation underway, frozen build/tests pending |
|
||||||
|
| 2026-09-06 17:24 UTC | Jason (durability direction) + darkwing (record) | Future runtime WAL requirement | Owner report recorded with flush/recovery/load-test obligations; inspector frozen and unchanged; no v1 investigation or retasking |
|
||||||
|
| 2026-09-06 17:32 UTC | Jason (workflow topics) + darkwing (capture) | Mechanical coordination backlog | n8n/custom, Kanban authority, stall definitions and low-babysitting recovery recorded; current inspector work unchanged |
|
||||||
|
| 2026-09-06 17:51 UTC | Jason (relayed Jarvis report) + darkwing (capture) | Evidence handoff observations | Three reported causes and remaining gates preserved; capability/receipt/watch lessons recorded; no investigation or retasking |
|
||||||
|
| 2026-09-06 18:17 UTC | rocko (build delivery) + darkwing (admission checks) | FI-ROCKO-4 | Build identity/contract issues found before independent review; five correction groups delivered; no new-code execution, commit or acceptance |
|
||||||
|
| 2026-09-06 18:39 UTC | rocko (r2 candidate) + darkwing (admission/clarification) | FI-FILBERT-5 | 239-file candidate verified; oracle red; strict schema/profile addendum proposed for review, delivery unconfirmed; code remains frozen |
|
||||||
|
| 2026-09-06 20:26 UTC | Jason (federation/comms direction) + darkwing (capture) | Future registry and mosaic comms | Hierarchy, UUID shortcuts, flags/examples and transport boundary recorded; implementation and current inspector unchanged |
|
||||||
|
| 2026-09-06 21:02 UTC | Jason (onboarding requirements) + darkwing (capture) | Future install/reconfiguration | Full required/optional setup and proposed CLI preserved; secret-input and bootstrap boundaries flagged; no implementation or retasking |
|
||||||
|
| 2026-09-06 21:04 UTC | darkwing | Status reconciliation / FI-ROCKO-5 | Found approved addendum verdict, closed stale wait, delivered scoped correction instruction; code review/demo remain pending |
|
||||||
|
| 2026-09-06 21:30 UTC | rocko (r3 delivery) + darkwing (verified admission) | FI-FILBERT-6 | 294-file candidate verified; green writer receipts; full code review requested, delivery unconfirmed; code frozen, no acceptance yet |
|
||||||
|
| 2026-09-07 14:17 UTC | darkwing | Demo readiness / FI-ROCKO-6 | Completed NOT APPROVED reconciled; five blocking fixes plus inventory improvement dispatched; live-branch integration gate recorded; no demo acceptance |
|
||||||
|
| 2026-09-07 14:29 UTC | darkwing | Demo integration gate | Suite boundaries verified and plan recorded; r4 return absent; no live execution, shared mutation or waiver |
|
||||||
|
| 2026-09-07 | Codex | Owner-requested temporary Darkwing host TUI launcher | Configured pinned Pi, launch context snapshots, explicit tools/skills and goal extension, separate resume/fresh sessions; offline launcher checks and real TUI /goal smoke passed without model requests; no commit or push |
|
||||||
|
| 2026-09-07 14:45 UTC | rocko (r4) + darkwing (admission) | FI-ROCKO-7 | 331-file r4 verified; remaining ordering fixes delivered; r3 live-test correction preserved; owner test-gate decision pending |
|
||||||
|
| 2026-09-07 14:48 UTC | Jason (demo test gate) + darkwing | FI-DEMO-GATE-1 | Two mixed live suites explicitly deferred for offline demo; retained checks/review; instruction queued to Rocko, r5 pending |
|
||||||
|
| 2026-09-07 | Codex | Darkwing launcher shim | Moved launch implementation to scripts/tui/launch.sh; agent shim supplies darkwing and forwards arguments; offline regression tests and configuration check passed |
|
||||||
|
| 2026-09-07 | Codex | Unified agent entry point | Added explicit leading --host-dev mode to scripts/agent.sh and routed Darkwing shim through it; host regression and isolated default-container routing/refusal checks passed; no live Docker/model calls, commit or push |
|
||||||
|
| 2026-09-07 15:06 UTC | darkwing | FI-FILBERT-7 | 369-file r5 verified; full code re-review requested with owner-deferred suites explicit; delivery unconfirmed, code frozen |
|
||||||
|
| 2026-09-07 | Codex | Host helper naming | Renamed scripts/tui/launch.sh to scripts/agent-host-dev.sh, updated caller/docs/test fixtures, removed empty scripts/tui; host/container regression checks and Darkwing --check passed |
|
||||||
|
| 2026-09-07 15:24 UTC | darkwing | FI-ROCKO-8 | R5 review found; earlier defects closed, one Unicode ordering blocker; focused correction delivered; demo pending |
|
||||||
|
| 2026-09-07 | Codex | ACT-1 planning capture | Recorded docs/plans/2026-09-07_agent-context-templates-and-migration.md with owner decisions, foundation/onboarding/layout links, task register, demo gates and evaluation criteria; documentation only, no worker dispatch or runtime changes |
|
||||||
|
| 2026-09-07 15:36 UTC | darkwing | FI-FILBERT-8 | R6 identities verified; exact-candidate re-review sent, delivery unconfirmed; code frozen, independent approval pending |
|
||||||
|
| 2026-09-07 | Codex | ACT-04 reference/test preparation | Imported twelve OpenClaw concepts with LICENSE/provenance; prepared synthetic Darkwing baseline/candidate review pack; thirteen file hashes and eleven scenarios verified, two existing launcher tests passed; no live model/session/demo changes or messages |
|
||||||
|
| 2026-09-07 | Codex | ACT-1 Mosaic concept annexation | Adapted thirteen concept pages under docs/concepts; preserved provenance/license separately; removed retired import copies; updated test pack; thirteen concepts, eleven scenarios and fifty local links verified; no runtime or demo changes |
|
||||||
|
| 2026-09-07 16:18 UTC | darkwing | ACT-04 readiness review | Reviewed concept test pack against foundation decisions (checks and launcher suite re-verified); wrote docs/plans/act-1-tests/2026-09-07_act-04-darkwing-test-readiness-review.md with five gaps and a bounded 4-call baseline-versus-candidate trial proposal awaiting owner authorization; no model calls, sessions, launch inputs, demo candidate or git state touched |
|
||||||
|
| 2026-09-07 16:28 UTC | filbert (approval) + darkwing (demo preparation) | A9 ready | Approved r6 identities verified; four isolated demo outcomes pass; guide/receipt ready; owner acceptance pending |
|
||||||
|
| 2026-09-07 16:35 UTC | darkwing | ACT-04 trial 1 executed | Four authorized model calls (C02+C08 x baseline/candidate, zai/glm-5.3-flash, pi 0.84.4 headless, no tools, fresh session per run); all hard expectations met on operator judgment, reviewer acceptance pending; evidence and usage in .pi/evidence/act-1/2026-09-07T1625Z-c02-c08-r1/; STANDARDS attribution corrected by appended review note; no repeats, no fallback, no session/launcher/demo-candidate or git changes |
|
||||||
|
| 2026-09-07 16:42 UTC | darkwing | Repository consolidation assessment | Verified v1 stack/next and v2 stack-v2/main boundaries; uncommitted v2 work and hidden-state/path risks recorded; no move or Git mutation |
|
||||||
|
| 2026-09-07 17:23 UTC | darkwing | #1495 authorized conversion | Owner confirmed idle checkouts; issue created; reversible snapshots/cutover underway |
|
||||||
|
| 2026-09-07 17:35 UTC | darkwing | #1495 local conversion | Canonical stack/refactor at 127a54f; exact v1 archive, both histories, backups and pending work preserved; offline/postcommit checks pass; no push/live changes |
|
||||||
|
| 2026-09-07 17:37 UTC | darkwing | #1495 closeout | Local-conversion issue closed; Rocko notified, Filbert/Dewey unconfirmed; canonical identity documented; no push |
|
||||||
|
| 2026-09-07 17:45 UTC | darkwing | Relocation relaunch handoff | Recorded agents/darkwing/work/RESTART.md and CONTEXT pointer; no launcher/private-session edits; current conversation continuity not assumed |
|
||||||
|
|
||||||
|
- darkwing — authorized wave complete: approved R5 transport-only fix 69f10a40, refactor plus 17 tags remotely verified; A9 recorded; #53 published inclusion evidence for Jason closure; registry review next; unrelated relocation work excluded.
|
||||||
-103
@@ -1,103 +0,0 @@
|
|||||||
# Documentation Sitemap
|
|
||||||
|
|
||||||
## Compaction refresh lease broker
|
|
||||||
|
|
||||||
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
|
||||||
- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, constrained recovery, fail-closed posture, distinct-principal deployment, and residual risk.
|
|
||||||
- [Constrained recovery skill](../packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md) — source-resident thin wrapper, receipt scope, C4 replay boundary, and T-C middle-drop disclosure.
|
|
||||||
- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements.
|
|
||||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
|
||||||
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
|
||||||
|
|
||||||
## CLI and skill management
|
|
||||||
|
|
||||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
|
||||||
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
|
||||||
|
|
||||||
## Fleet configuration management
|
|
||||||
|
|
||||||
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
|
||||||
- [Desired, derived, and observed state](fleet/concepts/desired-vs-observed-state.md) — roster authority, generation, ownership, and drift.
|
|
||||||
- [Identity, class, and runtime](fleet/concepts/identity-class-runtime.md) — stable name, display alias, class, runtime, provider, and model separation.
|
|
||||||
- [Role authority and leases](fleet/concepts/role-authority-and-leases.md) — validator/merge-gate separation and bounded lease authority.
|
|
||||||
- [Generated launch chain](fleet/concepts/generated-env-launch-chain.md) — strict data parsing, precedence, and quarantine.
|
|
||||||
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — schema, supported values, required fields, defaults, and constraints.
|
|
||||||
- [Fleet CLI reference](fleet/reference/cli.md) — local desired-state commands, JSON/exit behavior, and gateway-catalog separation.
|
|
||||||
- [Lifecycle transitions](fleet/reference/lifecycle-transitions.md) — create/apply/reboot/migration/rollback boundaries.
|
|
||||||
- [Status and drift](fleet/reference/status-and-drift.md) — desired/managed/observed state and current/future classifications.
|
|
||||||
- [Safe agent CRUD](fleet/how-to/create-update-delete-agent.md) — expected generation, dry-run, and partial-failure recovery.
|
|
||||||
- [Local lifecycle operations](fleet/how-to/start-stop-restart.md) — persisted versus one-shot actions.
|
|
||||||
- [Configurable interaction instance](fleet/how-to/configure-tess-interaction.md) and [validator instance](fleet/how-to/configure-ultron-validator.md) — generic identities and protected limits.
|
|
||||||
- [Reconcile and recover](fleet/operations/reconcile-and-recover.md) — plan/apply lock and recovery behavior.
|
|
||||||
- [Environment quarantine](fleet/operations/env-quarantine.md) — private evidence and value-free diagnostics.
|
|
||||||
- [Systemd/tmux troubleshooting](fleet/operations/systemd-tmux-troubleshooting.md) — socket, holder, unmanaged-session, and lock decisions.
|
|
||||||
- [Backup/restore boundary](fleet/operations/backup-restore.md) and [upgrade-assets hold](fleet/operations/upgrade-assets.md).
|
|
||||||
- [v1-to-v2 migration preview](fleet/migration/v1-to-v2.md) and [executable artifact dispositions](fleet/migration/example-profile-disposition.md).
|
|
||||||
- [FCM M5 closure evidence](reports/documentation/758-fleet-config-ia-closure.md) and [approved deferrals](reports/deferred/758-fleet-config-deferrals.md).
|
|
||||||
|
|
||||||
## Official channel plugins
|
|
||||||
|
|
||||||
- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts.
|
|
||||||
- [Discord administrator configuration](guides/admin-guide.md#discord-ingress-security) — secrets, allowlists, bindings, role policy, and thread permissions.
|
|
||||||
- [Discord user workflow](tess/USER-GUIDE.md#discord-conversations) — in-channel messages, mention-created threads, and runtime-transparent continuity.
|
|
||||||
- [Channel plugin authoring](tess/PLUGIN-GUIDE.md#official-channel-adapter-contract) — requirements for future Matrix, Slack, and other official adapters.
|
|
||||||
- [Discord package guide](../plugins/discord/README.md) — package behavior, configuration shape, and development commands.
|
|
||||||
|
|
||||||
## Native Kanban and canonical task SOT
|
|
||||||
|
|
||||||
- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0–P3 requirements and acceptance criteria.
|
|
||||||
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
|
|
||||||
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
|
|
||||||
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
|
|
||||||
- [KBN-101 database role split](native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) — rc.16 direct-Drizzle storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README/user-guide wrapper forms fail before masking and source-consistency rejects runner-delegation copy; held bootstrap → TLS/roles → run → verify → readiness; plus prior attestation, pgvector owner, classifier, TLS, activation, and certification prerequisite.
|
|
||||||
- [Federated tier data migration](guides/migrate-tier.md) — active KBN-101-07 operator route: runner-produced target attestation, dedicated non-DDL importer, and paired credential-/attestation-file references only.
|
|
||||||
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
|
|
||||||
- [KBN-101 exact-head security review](reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) — retained prior REQUEST CHANGES evidence for `da742ca`; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding.
|
|
||||||
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft.
|
|
||||||
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
|
|
||||||
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.
|
|
||||||
|
|
||||||
## Tess interaction agent
|
|
||||||
|
|
||||||
### Operator guides
|
|
||||||
|
|
||||||
- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows.
|
|
||||||
- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls.
|
|
||||||
- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow.
|
|
||||||
- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements.
|
|
||||||
- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures.
|
|
||||||
|
|
||||||
### Architecture and security
|
|
||||||
|
|
||||||
- [Architecture](tess/ARCHITECTURE.md)
|
|
||||||
- [Threat model](tess/THREAT-MODEL.md)
|
|
||||||
- [Mos coordination boundary](tess/MOS-COORDINATION.md)
|
|
||||||
- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md)
|
|
||||||
- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md)
|
|
||||||
|
|
||||||
### API contract
|
|
||||||
|
|
||||||
- [Tess OpenAPI contract](openapi-tess.yaml)
|
|
||||||
|
|
||||||
### Migration and qualification
|
|
||||||
|
|
||||||
- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md)
|
|
||||||
- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md)
|
|
||||||
- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md)
|
|
||||||
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
|
|
||||||
- [Verification matrix](tess/VERIFICATION-MATRIX.md)
|
|
||||||
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
|
|
||||||
- [Independent Option 2 runtime-portability qualification (2026-07-14)](tess/qualification/2026-07-14-option2-runtime-portability.md)
|
|
||||||
|
|
||||||
## Runtime-neutral Mos portability
|
|
||||||
|
|
||||||
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies.
|
|
||||||
- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754)
|
|
||||||
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
|
|
||||||
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
|
|
||||||
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
|
|
||||||
|
|
||||||
## Comms evolution — Matrix-native MACP (design, draft)
|
|
||||||
|
|
||||||
- [RFC-001 — MACP: a Mosaic-native, Matrix-native comms layer](rfcs/RFC-001-MACP-MATRIX-NATIVE.md) — Synapse + Mosaic appservice backbone, MACP v1 protocol, presence/escalation, federation, strangler migration off the Hermes MCP bridge.
|
|
||||||
- [RFC-002 — Install, configuration & topology for the Matrix/MACP comms system](rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md) — open-source install topology modes, ACME cert provisioning, pluggable secret backend, and config precedence.
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# SSO Providers
|
|
||||||
|
|
||||||
Mosaic Stack supports optional enterprise single sign-on through Better Auth's generic OAuth flow. The gateway mounts Better Auth under `/api/auth`, so every provider callback terminates at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/{providerId}
|
|
||||||
```
|
|
||||||
|
|
||||||
For the providers in this document:
|
|
||||||
|
|
||||||
- Authentik: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/authentik`
|
|
||||||
- WorkOS: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos`
|
|
||||||
- Keycloak: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak`
|
|
||||||
|
|
||||||
## Required environment variables
|
|
||||||
|
|
||||||
### Authentik
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic
|
|
||||||
AUTHENTIK_CLIENT_ID=...
|
|
||||||
AUTHENTIK_CLIENT_SECRET=...
|
|
||||||
```
|
|
||||||
|
|
||||||
### WorkOS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
WORKOS_ISSUER=https://your-company.authkit.app
|
|
||||||
WORKOS_CLIENT_ID=client_...
|
|
||||||
WORKOS_CLIENT_SECRET=...
|
|
||||||
NEXT_PUBLIC_WORKOS_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
`WORKOS_ISSUER` should be the WorkOS AuthKit issuer or custom auth domain, not the raw REST API hostname. Mosaic derives the OIDC discovery URL from that issuer.
|
|
||||||
|
|
||||||
### Keycloak
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
|
||||||
KEYCLOAK_CLIENT_ID=mosaic
|
|
||||||
KEYCLOAK_CLIENT_SECRET=...
|
|
||||||
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
If you prefer, you can keep the issuer split as:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KEYCLOAK_URL=https://auth.example.com
|
|
||||||
KEYCLOAK_REALM=master
|
|
||||||
```
|
|
||||||
|
|
||||||
The auth package will derive `KEYCLOAK_ISSUER` from those two values.
|
|
||||||
|
|
||||||
## WorkOS setup
|
|
||||||
|
|
||||||
1. In WorkOS, create or select the application that will back Mosaic login.
|
|
||||||
2. Configure an AuthKit domain or custom authentication domain for the application.
|
|
||||||
3. Add the redirect URI:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Copy the application's `client_id` and `client_secret` into `WORKOS_CLIENT_ID` and `WORKOS_CLIENT_SECRET`.
|
|
||||||
5. Set `WORKOS_ISSUER` to the AuthKit domain from step 2.
|
|
||||||
6. Create the WorkOS organization and attach the enterprise SSO connection you want Mosaic to use.
|
|
||||||
7. Set `NEXT_PUBLIC_WORKOS_ENABLED=true` in the web deployment so the login button is rendered.
|
|
||||||
|
|
||||||
## Keycloak setup
|
|
||||||
|
|
||||||
1. Start from an existing Keycloak realm or create a dedicated realm for Mosaic.
|
|
||||||
2. Create a confidential OIDC client named `mosaic` or your preferred client ID.
|
|
||||||
3. Set the valid redirect URI to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Set the web origin to the public Mosaic web URL.
|
|
||||||
5. Copy the client secret into `KEYCLOAK_CLIENT_SECRET`.
|
|
||||||
6. Set either `KEYCLOAK_ISSUER` directly or `KEYCLOAK_URL` + `KEYCLOAK_REALM`.
|
|
||||||
7. Set `NEXT_PUBLIC_KEYCLOAK_ENABLED=true` in the web deployment so the login button is rendered.
|
|
||||||
|
|
||||||
### Local Keycloak smoke test
|
|
||||||
|
|
||||||
If you want to test locally with Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm --name mosaic-keycloak \
|
|
||||||
-p 8080:8080 \
|
|
||||||
-e KEYCLOAK_ADMIN=admin \
|
|
||||||
-e KEYCLOAK_ADMIN_PASSWORD=admin \
|
|
||||||
quay.io/keycloak/keycloak:26.1 start-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Then configure:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KEYCLOAK_ISSUER=http://localhost:8080/realms/master
|
|
||||||
KEYCLOAK_CLIENT_ID=mosaic
|
|
||||||
KEYCLOAK_CLIENT_SECRET=...
|
|
||||||
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Web flow
|
|
||||||
|
|
||||||
The web login page renders provider buttons from `NEXT_PUBLIC_*_ENABLED` flags. Each button links to `/auth/provider/{providerId}`, and that page initiates Better Auth's `signIn.oauth2` flow before handing off to the provider.
|
|
||||||
|
|
||||||
## Failure mode
|
|
||||||
|
|
||||||
Provider config is optional, but partial config is rejected at startup. If any provider-specific env var is present without the full required set, `@mosaicstack/auth` throws a bootstrap error with the missing keys instead of silently registering a broken provider.
|
|
||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
# TOOLS.md — command and tool reference
|
||||||
|
|
||||||
|
On-demand reference for agent sessions (conductors, bootstrapping agents,
|
||||||
|
reviewers). `AGENTS.md` routes here; this file carries the depth: usage,
|
||||||
|
inputs/outputs, exit codes, and safety notes for every entry point.
|
||||||
|
|
||||||
|
Reading guide: system entry points are `scripts/*.sh` (bash) or invoked via
|
||||||
|
`node scripts/mosaic-task.mjs` (node). Host-side helpers under `tools/`
|
||||||
|
(tmux messaging, watchers, prose checker) are covered under Tools
|
||||||
|
(host-side) below. Every script fails closed — missing
|
||||||
|
or invalid configuration/policy refuses the operation with a nonzero exit
|
||||||
|
and changes nothing.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
| Command | Purpose | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten |
|
||||||
|
| `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version |
|
||||||
|
| `scripts/hello.sh` | One-shot startup request | Prints model response on stdout |
|
||||||
|
| `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills |
|
||||||
|
|
||||||
|
## Tasks (missions, runs, evidence)
|
||||||
|
|
||||||
|
| Command | Purpose | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `scripts/run-task.sh run <task.json>` | Execute a task | Immutable run record under `<dataRoot>/runs/` |
|
||||||
|
| `scripts/run-task.sh validate <task.json>` | Strict validation | Writes nothing |
|
||||||
|
| `node scripts/mosaic-task.mjs show <runId>` | Inspect a run | Full record + snapshots + artifacts |
|
||||||
|
| `node scripts/mosaic-task.mjs list` | List runs | task/workspace/session columns |
|
||||||
|
| `node scripts/mosaic-task.mjs retry <runId>` | Re-execute a run's snapshot | New run dir; `retriedFrom` lineage recorded |
|
||||||
|
| `node scripts/mosaic-task.mjs prune [--keep=N] [--yes]` | Retention | Dry-run default; receipt in `runs/.pruned.log` |
|
||||||
|
| `node scripts/mosaic-task.mjs resolve-role <roleFile>` | Validate a role contract | Prints `MOSAIC_ROLE_TOOLS` / `MOSAIC_ROLE_NETWORK`; config-free |
|
||||||
|
|
||||||
|
Task fields: `prompt` (required), `mission` (path), `expectExact`,
|
||||||
|
`timeoutSeconds` (5–600), `workspace` (`:run` or named), `capabilities.tools`
|
||||||
|
(allowlist: read write edit bash grep find ls), `session`,
|
||||||
|
`sessionForkFrom` (requires `session`). Mission fields: `objective`,
|
||||||
|
`directives[]`, optional governing `capabilities.tools`. Policy: a task may
|
||||||
|
narrow a mission's tools, never widen; empty intersection = tool-free run.
|
||||||
|
|
||||||
|
## Agent (interactive TUI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/agent.sh <name> [--mission <file>] [--workspace <ws>] [--session <s>] [--tools <list>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Launches an interactive pi TUI inside the container with the four immutable
|
||||||
|
contracts + optional mission + agent identity as its system prompt,
|
||||||
|
persistent named session, optional workspace. Exit with `/quit`.
|
||||||
|
|
||||||
|
A seat role (`agent.json` `role`) binds to `roles/<role>.json` (M18): the
|
||||||
|
contract's tools are a ceiling the seat definition or `--tools` may narrow,
|
||||||
|
never escalate past. Missing/invalid contract refuses the launch; empty
|
||||||
|
intersection = loud tool-free seat. An explicit `MOSAIC_AGENTS_DIR` override
|
||||||
|
that cannot resolve the named seat also refuses (#46) — unset the override
|
||||||
|
for the M13 plain governed TUI. `--auth <account>` injects
|
||||||
|
`auth.<account>.json` (beside the active credential file) as the launch's
|
||||||
|
`PI_AUTH_FILE`; a missing/invalid account refuses (M19).
|
||||||
|
|
||||||
|
For native repository development, opt in with a **leading** `--host-dev`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/agent.sh --host-dev darkwing [--fresh] [--check] [--soul FILE] [--constitution FILE] [--user FILE]
|
||||||
|
```
|
||||||
|
|
||||||
|
This mode delegates to `scripts/agent-host-dev.sh`, uses host Pi and repository
|
||||||
|
tools/skills plus the development goal extension, and keeps its own sessions
|
||||||
|
under `.pi/state/<name>/`. It uses native Pi authentication and does not run
|
||||||
|
container release alignment or apply managed seat role ceilings. It is a host
|
||||||
|
development session, not a sandboxed worker. Container-only flags such as
|
||||||
|
`--auth`, `--mission`, and `--tools` are rejected in this mode. Omitting
|
||||||
|
`--host-dev` retains the existing container lifecycle and policy checks;
|
||||||
|
container failures never trigger a host fallback. Darkwing's agent-local shim
|
||||||
|
selects host development explicitly. See `agents/darkwing/README.md`.
|
||||||
|
|
||||||
|
## Auth (credentials)
|
||||||
|
|
||||||
|
Credential checkpoint over pi's auth model (one `auth.json` keyed by
|
||||||
|
provider; resolution order `--api-key` → `auth.json` → env → models.json).
|
||||||
|
No credential material is ever printed — provider names, credential types,
|
||||||
|
and env var NAMES only.
|
||||||
|
|
||||||
|
Ownership rule (#48): `~/.pi` is read-only to the stack, permanently. The
|
||||||
|
only interaction is the existing read-only container mount of the default
|
||||||
|
credential (`PI_AUTH_FILE`, default `~/.pi/agent/auth.json`). Mosaic-managed
|
||||||
|
accounts live under the data root: `<dataRoot>/auth/<account>.json`, perms
|
||||||
|
0600 (mirroring `scripts/gitea-api.sh` hygiene — loose perms are flagged in
|
||||||
|
listings and refused by `--auth`).
|
||||||
|
|
||||||
|
| Command | Purpose | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `scripts/auth.sh status` | Report both credential sources | Default harness credential (read-only) + mosaic-managed accounts; never prints material |
|
||||||
|
| `scripts/auth.sh accounts` | List mosaic-managed accounts | Under the data root; marks the active one; flags non-0600 |
|
||||||
|
|
||||||
|
`agent.sh --auth <account>` injects `<dataRoot>/auth/<account>.json` as the
|
||||||
|
launch's `PI_AUTH_FILE`; missing/symlinked/non-0600 accounts refuse.
|
||||||
|
Headless task runs keep the default credential.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
| Command | Purpose | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `scripts/release.sh package` | Build + tag the release image | Tag: `mosaic-poc-agent:<pi>-r<release>` |
|
||||||
|
| `scripts/release.sh activate` | Health gate → atomic pointer swap | `--fault-injection` proves the refusal path |
|
||||||
|
| `scripts/release.sh rollback` | Health-gated return to previous | Refuses if image missing |
|
||||||
|
| `scripts/release.sh status` | Release, tag, active pointer, log | Safe on empty state |
|
||||||
|
| `scripts/release.sh ensure` | Self-determination: align active pointer to `RELEASE` | Fast path restores a missing/mismatched pointer without a gate; slow path packages + health-gates first. Invoked automatically at launch |
|
||||||
|
|
||||||
|
## Conductor (worker patches)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/conductor-apply.sh <runId> [--dry-run]
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-applies a worker's patch under `roles/conductor-policy.json`:
|
||||||
|
succeeded run → clean target tree → path allowlist → syntax gates →
|
||||||
|
apply → policy suites → attribution commit. Any failure reverts.
|
||||||
|
Push is never automatic.
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
| Command | Purpose | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `scripts/reset.sh` | Delete the data root | Triple-safety-checked (path, symlink, ownership marker) |
|
||||||
|
| `scripts/test-config.sh` | Config selftests (no Docker) | 24 cases |
|
||||||
|
| `scripts/test-task.sh` | Task selftests + live cases | 90 cases |
|
||||||
|
| `scripts/test-release.sh` | Release selftests | 14 cases |
|
||||||
|
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
|
||||||
|
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
|
||||||
|
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper | Token never on argv/stdout |
|
||||||
|
|
||||||
|
## Tools (host-side)
|
||||||
|
|
||||||
|
Host-side helpers under `tools/`, outside the `scripts/` command surface.
|
||||||
|
Per-tool READMEs: `tools/tmux/README.md` and `tools/unslop-hook/README.md`.
|
||||||
|
|
||||||
|
| Command | Purpose | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `tools/tmux/agent-send.sh` | Inter-agent tmux message with addressing preamble | Reliable submit (bracketed paste, Enter flush, draft detection); ships `send-message.sh` over ssh for remote panes (remote needs only bash + tmux + base64) |
|
||||||
|
| `tools/agent-watch/agent-watch.sh` | Condition watcher per agent seat | One transient systemd `--user` timer + service per watch; fires `agent-send.sh` when the condition command exits 0 |
|
||||||
|
| `node tools/unslop-hook/unslop-check.js <file>` | Mechanical AI-tell prose check | Dependency-free node CLI + module driven by `lists.json`; `extension.ts` is the pi extension wrapper |
|
||||||
|
|
||||||
|
`agent-send.sh` prepends the preamble
|
||||||
|
`[<src_host>:<src_session> -> <dst_host>:<dst_session>]`; `-C`/`--class` adds
|
||||||
|
a ` class=<CLASS>` token (`terminal-log`, `actionable`, `human`, `reaction`,
|
||||||
|
`digest`; consumers treat an absent class as `actionable`). Flags: `-s` dst
|
||||||
|
session (required) · `-H` ssh target for a remote pane · `-L` named tmux
|
||||||
|
socket · `-n` dst hostname for the preamble · `-m`/`-f`/stdin message body ·
|
||||||
|
`-S` source-label override · `-r N` Enter-flush attempts (default 2) · `-v`
|
||||||
|
verbose · `-h` help. Exit codes: `0` delivered/queued · `1` target not found ·
|
||||||
|
`2` still draft · `3` usage error · `4` ambiguous socket (the session exists
|
||||||
|
on more than one tmux server; disambiguate with `-L` or `MOSAIC_TMUX_SOCKET`).
|
||||||
|
|
||||||
|
`agent-watch.sh` subcommands: `start --name <id> --session <session> --when
|
||||||
|
'<shell command; exit 0 = met>' --message <text>` with `--class`,
|
||||||
|
`--interval` (default 30), `--timeout` (default 3600), `--repeat`,
|
||||||
|
`--quiet-timeout`, `--socket` · `list` · `status [--json]` · `stop <name>` ·
|
||||||
|
`log <name>` · `meta-install [--interval 300] [--unit-name <unit>]` ·
|
||||||
|
`meta-remove [--unit-name <unit>]`. Interval floor is 10s (a watcher is a
|
||||||
|
fallback cadence, never a tight poll); hidden `_tick`/`_scan` subcommands run
|
||||||
|
inside the systemd services. `status` exit codes: `0` clean · `3` any stale
|
||||||
|
watch or dead meta-watch · `6` systemd user bus unreachable. Delivery goes
|
||||||
|
through `agent-send.sh`; rc `2` means the text reached the pane as an
|
||||||
|
unsubmitted draft, which counts as delivered and is not retried (other
|
||||||
|
failures retry twice, then the watch gives up). Watches are one-shot by
|
||||||
|
default; `--repeat` re-arms. Notices carry a `[watch:<name>]` prefix.
|
||||||
|
|
||||||
|
`unslop-check.js` checks a file (or stdin) against the word, phrase,
|
||||||
|
punctuation-density, and pattern lists in `lists.json`, stripping fenced and
|
||||||
|
inline code first so a quoted mention never flags. Invocation:
|
||||||
|
`node tools/unslop-hook/unslop-check.js <file>`; `UNSLOP_LISTS=<path>`
|
||||||
|
overrides the lists location. Exit codes: `0` clean · `1` violations (findings
|
||||||
|
printed as JSON on stdout) · `2` gate broken (invalid lists or unreadable
|
||||||
|
input; error on stderr, never a clean verdict).
|
||||||
|
|
||||||
|
## Exit-code convention
|
||||||
|
|
||||||
|
`0` success · `1` operation failed · `2` invalid data/configuration ·
|
||||||
|
`3` configuration missing for a read operation · `4` usage/file/environment
|
||||||
|
problem. Scripts print diagnostics on stderr; model responses (and only
|
||||||
|
model responses) on stdout.
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
# ADR: Optional AI egress gateways for runtime-neutral Mos
|
|
||||||
|
|
||||||
**Status:** Proposed for controlled prototypes; not approved as Mosaic core
|
|
||||||
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
|
|
||||||
**Issues:** #754, #755
|
|
||||||
|
|
||||||
**Decision owner:** Mosaic Gateway / provider-adapter architecture
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The emergency Mos continuity path kept Claude Code as the harness and translated Anthropic Messages traffic to Codex OAuth through a small localhost proxy. That preserved the existing Claude Discord plugin and transcript, but exposed two architectural facts:
|
|
||||||
|
|
||||||
1. Harness identity, channel entitlement, provider credentials, and inference transport are separate concerns.
|
|
||||||
2. A generic AI gateway can improve provider routing, budgets, and observability, but must not become Mosaic's identity, authorization, tenant, or orchestration boundary.
|
|
||||||
|
|
||||||
The Tess qualification report also found that current provider rebinding is not identity-continuous failover. Mosaic still needs a logical agent identity, durable connector lease/fencing, canonical handoff/checkpoint, exactly-once receipts, concrete harness adapters, and cross-harness rollback E2E.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Mosaic MAY support LiteLLM, Bifrost, the purpose-built Claude/Codex proxy, or future gateways as optional egress implementations behind `IProviderAdapter` / `AgentRuntimeProvider`.
|
|
||||||
|
|
||||||
Mosaic Gateway remains authoritative for:
|
|
||||||
|
|
||||||
- authenticated actor and tenant identity;
|
|
||||||
- logical agent identity and connector binding;
|
|
||||||
- authorization, approval, and policy;
|
|
||||||
- lease epoch and stale-holder fencing;
|
|
||||||
- audit correlation and redaction;
|
|
||||||
- canonical handoff/checkpoint state;
|
|
||||||
- idempotency and side-effect receipts.
|
|
||||||
|
|
||||||
An egress gateway MUST NOT:
|
|
||||||
|
|
||||||
- receive channel ingress directly;
|
|
||||||
- authorize tools or connector ownership;
|
|
||||||
- define Mosaic tenant or agent identity;
|
|
||||||
- persist raw Mosaic handoffs or channel credentials;
|
|
||||||
- bypass adapter capability negotiation;
|
|
||||||
- silently fail over when policy, lease, or provider health is uncertain.
|
|
||||||
|
|
||||||
Allowed topology:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Discord / Matrix / CLI / web
|
|
||||||
↓
|
|
||||||
Mosaic Gateway: identity, authz, lease/fence, approvals, audit
|
|
||||||
↓
|
|
||||||
IProviderAdapter / AgentRuntimeProvider
|
|
||||||
↓
|
|
||||||
optional egress gateway
|
|
||||||
↓
|
|
||||||
upstream provider or subscription-backed OAuth session
|
|
||||||
```
|
|
||||||
|
|
||||||
## Candidate assessment
|
|
||||||
|
|
||||||
### Purpose-built `raine/claude-code-proxy`
|
|
||||||
|
|
||||||
**Disposition:** Approved only for the verified emergency localhost bridge.
|
|
||||||
|
|
||||||
Strengths:
|
|
||||||
|
|
||||||
- explicit Codex device OAuth flow;
|
|
||||||
- small operational surface;
|
|
||||||
- Anthropic Messages translation suitable for Claude Code;
|
|
||||||
- model and reasoning-effort enforcement;
|
|
||||||
- straightforward loopback systemd supervision and rollback.
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
|
|
||||||
- not a Mosaic multi-tenant control plane;
|
|
||||||
- Claude built-in channels still depend on Claude subscription entitlement and feature lookup;
|
|
||||||
- model aliases can obscure the upstream model unless proxy policy/logs are treated as evidence;
|
|
||||||
- no replacement for connector leasing, canonical handoff, or exactly-once effects.
|
|
||||||
|
|
||||||
### LiteLLM
|
|
||||||
|
|
||||||
**Disposition:** Candidate for a formal adapter-only prototype and terms/security review.
|
|
||||||
|
|
||||||
Current documentation states that ChatGPT subscription access is available through an OAuth device-code flow. LiteLLM also provides broad provider routing, virtual keys, budgets, observability, and OpenAI/Anthropic-compatible surfaces.
|
|
||||||
|
|
||||||
Required prototype gates:
|
|
||||||
|
|
||||||
- verify the exact ChatGPT subscription OAuth flow and supported models against current provider terms;
|
|
||||||
- document token location, encryption, revocation, refresh, scope, and incident response;
|
|
||||||
- prove tenant isolation and prevent virtual keys from becoming Mosaic principals;
|
|
||||||
- verify streaming, tool calls, reasoning controls, cancellation, and idempotency metadata;
|
|
||||||
- fail closed instead of selecting an unhealthy provider merely to return a result;
|
|
||||||
- demonstrate that Mosaic audit correlation survives gateway retries/failover;
|
|
||||||
- keep channel ingress and connector credentials outside LiteLLM.
|
|
||||||
|
|
||||||
Source references:
|
|
||||||
|
|
||||||
- [LiteLLM ChatGPT subscription provider](https://docs.litellm.ai/docs/providers/chatgpt)
|
|
||||||
- [LiteLLM providers](https://docs.litellm.ai/docs/providers)
|
|
||||||
|
|
||||||
### Bifrost
|
|
||||||
|
|
||||||
**Disposition:** Candidate for governance/routing research; subscription OAuth compatibility unverified.
|
|
||||||
|
|
||||||
Useful concepts include virtual keys, budgets, rate limits, weighted load balancing, and automatic provider failover. Those features may inform Mosaic egress policy, but Bifrost virtual keys are downstream credentials—not Mosaic actors or tenants.
|
|
||||||
|
|
||||||
Required prototype gates:
|
|
||||||
|
|
||||||
- verify Codex/ChatGPT subscription OAuth rather than assuming API-key compatibility;
|
|
||||||
- map budgets and virtual keys to server-derived Mosaic tenants without duplicating authority;
|
|
||||||
- prove failover does not violate connector lease, approval, or exactly-once semantics;
|
|
||||||
- ensure request/response logs are redacted before persistence;
|
|
||||||
- disable or constrain automatic failover when policy or side-effect state is ambiguous.
|
|
||||||
|
|
||||||
Source references:
|
|
||||||
|
|
||||||
- [Bifrost overview](https://docs.getbifrost.ai/overview)
|
|
||||||
- [Bifrost repository](https://github.com/maximhq/bifrost)
|
|
||||||
|
|
||||||
### `teremterem/claude-code-gpt-5-codex`
|
|
||||||
|
|
||||||
**Disposition:** Not selected as the emergency implementation; useful as a historical LiteLLM recipe.
|
|
||||||
|
|
||||||
The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated Claude users to log out, and documents a Claude Web Search schema incompatibility. Logging Claude out conflicts with the channel-entitlement requirement observed in the live Mos cutover. The repository therefore does not, as provided, satisfy subscription-OAuth plus built-in-channel continuity.
|
|
||||||
|
|
||||||
Source references:
|
|
||||||
|
|
||||||
- [Repository](https://github.com/teremterem/claude-code-gpt-5-codex)
|
|
||||||
- [Environment template](https://github.com/teremterem/claude-code-gpt-5-codex/blob/main/.env.template)
|
|
||||||
|
|
||||||
## Security consequences
|
|
||||||
|
|
||||||
- Subscription OAuth grants are high-value credentials and require the same lifecycle controls as service credentials.
|
|
||||||
- Downstream virtual keys reduce provider-key exposure but do not establish user, tenant, or agent authority.
|
|
||||||
- Automatic retry/failover can duplicate tool or external side effects unless Mosaic owns operation IDs and receipts.
|
|
||||||
- Gateway telemetry can contain prompts, tool schemas, and model output; redaction and retention policy must apply before persistence.
|
|
||||||
- A localhost unauthenticated translation endpoint must remain loopback-only and process-isolated.
|
|
||||||
|
|
||||||
## Acceptance before production use
|
|
||||||
|
|
||||||
1. Threat model and provider-terms review approved.
|
|
||||||
2. Credential lifecycle and revocation drill documented and exercised.
|
|
||||||
3. Adapter contract tests pass for streaming, tools, cancellation, reasoning policy, errors, and audit correlation.
|
|
||||||
4. Tenant-bound authorization remains entirely in Mosaic Gateway.
|
|
||||||
5. Failure injection proves no duplicate side effects across retries or provider failover.
|
|
||||||
6. Rollback to the prior provider path is exercised.
|
|
||||||
7. Independent code and security reviews approve the exact deployed revision.
|
|
||||||
|
|
||||||
## Follow-up
|
|
||||||
|
|
||||||
- #754 owns cross-harness logical identity, checkpoint, receipt, adapter, and failover work.
|
|
||||||
- #755 / PR #757 implements the first logical identity and connector lease/fencing boundary.
|
|
||||||
- A later issue should prototype LiteLLM and Bifrost behind the provider adapter after #755 is merged and independently qualified.
|
|
||||||
@@ -1,751 +0,0 @@
|
|||||||
# Channel Protocol Architecture
|
|
||||||
|
|
||||||
**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative
|
|
||||||
**Authors:** Mosaic Core Team
|
|
||||||
**Last Updated:** 2026-07-14
|
|
||||||
**Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels).
|
|
||||||
|
|
||||||
The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups:
|
|
||||||
|
|
||||||
1. `OfficialChannelAdapter` — transport lifecycle and connection health.
|
|
||||||
2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data.
|
|
||||||
3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address.
|
|
||||||
4. `ChannelResponseTargetDto` — channel/thread destination for replies.
|
|
||||||
|
|
||||||
All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M7-001: OfficialChannelAdapter Interface
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface OfficialChannelAdapter {
|
|
||||||
/** Stable, lowercase adapter identifier such as "discord" or "matrix". */
|
|
||||||
readonly name: string;
|
|
||||||
/** Establish both native-channel and gateway connections. */
|
|
||||||
start(): Promise<void>;
|
|
||||||
/** Gracefully close connections and release resources. */
|
|
||||||
stop(): Promise<void>;
|
|
||||||
/** Best-effort health; ordinary disconnection is a result, not an exception. */
|
|
||||||
health(): Promise<{
|
|
||||||
status: 'connected' | 'degraded' | 'disconnected';
|
|
||||||
detail?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned.
|
|
||||||
|
|
||||||
### Stable conversation route
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ChannelConversationRouteDto {
|
|
||||||
bindingId: string;
|
|
||||||
logicalAgentId: string;
|
|
||||||
conversationId: string;
|
|
||||||
channelName: string;
|
|
||||||
authorizationChannelId: string;
|
|
||||||
responseTarget: { channelId: string; threadId?: string };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target.
|
|
||||||
|
|
||||||
### Typed ingress and egress ports
|
|
||||||
|
|
||||||
`ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core.
|
|
||||||
|
|
||||||
`ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port.
|
|
||||||
|
|
||||||
Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics.
|
|
||||||
|
|
||||||
### Adapter Registration
|
|
||||||
|
|
||||||
Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract.
|
|
||||||
|
|
||||||
```
|
|
||||||
ChannelRegistry
|
|
||||||
└── register(adapter: OfficialChannelAdapter): void
|
|
||||||
└── getAdapter(name: string): OfficialChannelAdapter | null
|
|
||||||
└── listAdapters(): OfficialChannelAdapter[]
|
|
||||||
└── healthAll(): Promise<Record<string, AdapterHealth>>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M7-002: ChannelMessageDto Protocol
|
|
||||||
|
|
||||||
### Canonical Message Format
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ChannelMessageDto {
|
|
||||||
/**
|
|
||||||
* Globally unique message ID.
|
|
||||||
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
|
|
||||||
* when sending. Channel-native IDs are stored in metadata.channelMessageId.
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Channel-native room/conversation/channel identifier.
|
|
||||||
* The adapter populates this from the inbound message.
|
|
||||||
* For outbound messages, the caller supplies the target channel.
|
|
||||||
*/
|
|
||||||
channelName: string;
|
|
||||||
channelId: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Channel-native identifier of the message sender.
|
|
||||||
* For Mosaic-originated messages this is the Mosaic userId or agentId.
|
|
||||||
*/
|
|
||||||
senderId: string;
|
|
||||||
|
|
||||||
/** Sender classification. */
|
|
||||||
senderKind: 'user' | 'agent' | 'system';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Textual content of the message.
|
|
||||||
* For non-text content types (image, file) this may be an empty string
|
|
||||||
* or an alt-text description; the actual payload is in `attachments`.
|
|
||||||
*/
|
|
||||||
content: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hint for how `content` should be interpreted and rendered.
|
|
||||||
* - "text" — plain text, no special rendering
|
|
||||||
* - "markdown" — CommonMark markdown
|
|
||||||
* - "code" — code block (use metadata.language for the language tag)
|
|
||||||
* - "image" — binary image; content is empty, see attachments
|
|
||||||
* - "file" — binary file; content is empty, see attachments
|
|
||||||
*/
|
|
||||||
contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Arbitrary key-value metadata for channel-specific extension fields.
|
|
||||||
* Examples: { channelMessageId, language, reactionEmoji, channelType }.
|
|
||||||
* Adapters should store channel-native IDs here so round-trip correlation
|
|
||||||
* is possible without altering the canonical fields.
|
|
||||||
*/
|
|
||||||
metadata: Readonly<Record<string, ChannelMetadataValue>>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional thread or reply-chain identifier.
|
|
||||||
* For threaded channels (Matrix, Discord threads, Telegram topics) this
|
|
||||||
* groups messages into a logical thread scoped to the same channelId.
|
|
||||||
*/
|
|
||||||
threadId?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The canonical message ID this message is a reply to.
|
|
||||||
* Maps to channel-native reply/quote mechanisms in each adapter.
|
|
||||||
*/
|
|
||||||
replyToId?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binary or URI-referenced attachments.
|
|
||||||
* Each attachment carries its MIME type and a URL or base64 payload.
|
|
||||||
*/
|
|
||||||
attachments?: readonly ChannelAttachmentDto[];
|
|
||||||
|
|
||||||
/** ISO-8601 wall-clock timestamp when the message was sent/received. */
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChannelAttachmentDto {
|
|
||||||
/** Channel-native attachment identifier. */
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/** Filename or display name. */
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/** MIME type when supplied by the channel. */
|
|
||||||
mimeType: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
|
|
||||||
* Adapters that receive file uploads SHOULD store to object storage and
|
|
||||||
* populate a stable URL here rather than embedding the raw bytes.
|
|
||||||
*/
|
|
||||||
url: string;
|
|
||||||
|
|
||||||
/** Size in bytes, if known. */
|
|
||||||
sizeBytes?: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Channel Translation Reference
|
|
||||||
|
|
||||||
The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`.
|
|
||||||
|
|
||||||
### Matrix
|
|
||||||
|
|
||||||
| ChannelMessageDto field | Matrix equivalent |
|
|
||||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
|
|
||||||
| `channelId` | Matrix room ID (`!roomid:homeserver`) |
|
|
||||||
| `senderId` | Matrix user ID (`@user:homeserver`) |
|
|
||||||
| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
|
|
||||||
| `content` | `event.content.body` |
|
|
||||||
| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
|
|
||||||
| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` |
|
|
||||||
| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` |
|
|
||||||
| `attachments` | Populated from `url` in `m.image` / `m.file` events |
|
|
||||||
| `timestamp` | `new Date(event.origin_server_ts)` |
|
|
||||||
| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` |
|
|
||||||
|
|
||||||
**Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Discord
|
|
||||||
|
|
||||||
| ChannelMessageDto field | Discord equivalent |
|
|
||||||
| ----------------------- | ----------------------------------------------------------------------- |
|
|
||||||
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
|
|
||||||
| `channelId` | Discord channel ID (snowflake string) |
|
|
||||||
| `senderId` | Discord user ID (snowflake) |
|
|
||||||
| `senderKind` | `"user"` for human members; `"agent"` for bot messages |
|
|
||||||
| `content` | `message.content` |
|
|
||||||
| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) |
|
|
||||||
| `threadId` | `message.thread.id` when the message is inside a thread channel |
|
|
||||||
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
|
|
||||||
| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` |
|
|
||||||
| `timestamp` | `new Date(message.timestamp)` |
|
|
||||||
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` |
|
|
||||||
|
|
||||||
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
|
||||||
|
|
||||||
### Discord routing and thread policy
|
|
||||||
|
|
||||||
A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID.
|
|
||||||
|
|
||||||
| Inbound location/trigger | Conversation and response target |
|
|
||||||
| ------------------------------------------ | --------------------------------------------------------------- |
|
|
||||||
| Authorized untagged parent-channel message | Parent channel; response is sent in-channel |
|
|
||||||
| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread |
|
|
||||||
| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread |
|
|
||||||
| `/approve` or `/stop <approval>` | Current parent/thread durable session; no new topic is created |
|
|
||||||
|
|
||||||
Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target.
|
|
||||||
|
|
||||||
### Discord service ingress security
|
|
||||||
|
|
||||||
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Telegram
|
|
||||||
|
|
||||||
| ChannelMessageDto field | Telegram equivalent |
|
|
||||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
|
|
||||||
| `channelId` | Telegram `chat_id` (integer as string) |
|
|
||||||
| `senderId` | Telegram `from.id` (integer as string) |
|
|
||||||
| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages |
|
|
||||||
| `content` | `message.text` or `message.caption` |
|
|
||||||
| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
|
|
||||||
| `threadId` | `message.message_thread_id` (for supergroup topics) |
|
|
||||||
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
|
|
||||||
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` |
|
|
||||||
| `timestamp` | `new Date(message.date * 1000)` |
|
|
||||||
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
|
|
||||||
|
|
||||||
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TUI (Terminal UI)
|
|
||||||
|
|
||||||
The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel.
|
|
||||||
|
|
||||||
| ChannelMessageDto field | TUI equivalent |
|
|
||||||
| ----------------------- | ------------------------------------------------------------------ |
|
|
||||||
| `id` | Generated UUID (TUI has no native message IDs) |
|
|
||||||
| `channelId` | `"tui:<conversationId>"` — the active conversation ID |
|
|
||||||
| `senderId` | Authenticated Mosaic `userId` |
|
|
||||||
| `senderKind` | `"user"` for human input; `"agent"` for agent replies |
|
|
||||||
| `content` | Raw text from stdin / agent output |
|
|
||||||
| `contentKind` | `"text"` for input; `"markdown"` for agent responses |
|
|
||||||
| `threadId` | Not used (TUI sessions are linear) |
|
|
||||||
| `replyToId` | Not used |
|
|
||||||
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
|
|
||||||
| `timestamp` | `new Date()` at the moment of send |
|
|
||||||
| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` |
|
|
||||||
|
|
||||||
**Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### WebUI
|
|
||||||
|
|
||||||
The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).
|
|
||||||
|
|
||||||
| ChannelMessageDto field | WebUI equivalent |
|
|
||||||
| ----------------------- | ------------------------------------------------------------ |
|
|
||||||
| `id` | Generated UUID; echoed back in the WebSocket event |
|
|
||||||
| `channelId` | `"webui:<conversationId>"` |
|
|
||||||
| `senderId` | Authenticated Mosaic `userId` |
|
|
||||||
| `senderKind` | `"user"` for browser input; `"agent"` for agent responses |
|
|
||||||
| `content` | Message text from the input field |
|
|
||||||
| `contentKind` | `"text"` or `"markdown"` |
|
|
||||||
| `threadId` | Not used (conversation model handles threading) |
|
|
||||||
| `replyToId` | Message ID the user replied to (UI reply affordance) |
|
|
||||||
| `attachments` | Files uploaded via the file picker; stored to object storage |
|
|
||||||
| `timestamp` | `new Date()` at send, or server timestamp from event |
|
|
||||||
| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` |
|
|
||||||
|
|
||||||
**Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Identity Mapping
|
|
||||||
|
|
||||||
Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity.
|
|
||||||
|
|
||||||
The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).
|
|
||||||
|
|
||||||
```
|
|
||||||
channel_identities
|
|
||||||
channel_name TEXT -- e.g. "matrix", "discord"
|
|
||||||
channel_user_id TEXT -- channel-native user identifier
|
|
||||||
mosaic_user_id TEXT -- FK to users.id
|
|
||||||
linked_at TIMESTAMP
|
|
||||||
PRIMARY KEY (channel_name, channel_user_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
Identity linking flows (OAuth dance, deep-link verification token, etc.) are out of scope for this document and will be specified in a separate identity-linking protocol document.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling Conventions
|
|
||||||
|
|
||||||
- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready.
|
|
||||||
- `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried.
|
|
||||||
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
|
|
||||||
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
|
|
||||||
|
|
||||||
Current version: **1.0.0**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M7-003: Matrix Integration Design
|
|
||||||
|
|
||||||
### Homeserver Choice
|
|
||||||
|
|
||||||
Mosaic uses **Conduit** as the Matrix homeserver. Conduit is written in Rust, ships as a single binary, and has minimal operational overhead compared to Synapse or Dendrite. It supports the full Matrix Client-Server and Application Service APIs required by Mosaic.
|
|
||||||
|
|
||||||
Recommended deployment: Conduit runs as a Docker container alongside the Mosaic stack. A single Conduit instance is sufficient for most self-hosted deployments. Conduit's embedded RocksDB storage means no separate database is required for the homeserver itself.
|
|
||||||
|
|
||||||
### Appservice Registration
|
|
||||||
|
|
||||||
Mosaic registers with the Conduit homeserver as a Matrix **Application Service (appservice)**. This gives Mosaic the ability to:
|
|
||||||
|
|
||||||
- Create and control ghost users (virtual Matrix users representing Mosaic agents and provisioned accounts).
|
|
||||||
- Receive all events sent to rooms within the appservice's namespace without polling.
|
|
||||||
- Send events on behalf of ghost users without separate authentication.
|
|
||||||
|
|
||||||
Registration is done via a YAML registration file (`mosaic-appservice.yaml`) placed in Conduit's configuration directory:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
id: mosaic
|
|
||||||
url: http://gateway:3000/_matrix/appservice
|
|
||||||
as_token: <random-secret>
|
|
||||||
hs_token: <random-secret>
|
|
||||||
sender_localpart: mosaic-bot
|
|
||||||
namespaces:
|
|
||||||
users:
|
|
||||||
- exclusive: true
|
|
||||||
regex: '@mosaic_.*:homeserver'
|
|
||||||
rooms:
|
|
||||||
- exclusive: false
|
|
||||||
regex: '.*'
|
|
||||||
aliases:
|
|
||||||
- exclusive: true
|
|
||||||
regex: '#mosaic-.*:homeserver'
|
|
||||||
```
|
|
||||||
|
|
||||||
The gateway exposes `/_matrix/appservice` endpoints to receive push events from Conduit. The `as_token` and `hs_token` are stored in Vault and injected at startup.
|
|
||||||
|
|
||||||
### Room ↔ Conversation Mapping
|
|
||||||
|
|
||||||
Each Mosaic conversation maps to a single Matrix room. The mapping is stored in the database:
|
|
||||||
|
|
||||||
```
|
|
||||||
conversation_matrix_rooms
|
|
||||||
conversation_id TEXT -- FK to conversations.id
|
|
||||||
room_id TEXT -- Matrix room ID (!roomid:homeserver)
|
|
||||||
created_at TIMESTAMP
|
|
||||||
PRIMARY KEY (conversation_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
Room creation is handled by the appservice on the first Matrix access to a conversation. Room names follow the pattern `Mosaic: <conversation title>`. Room topics contain the conversation ID for correlation.
|
|
||||||
|
|
||||||
When a conversation is deleted or archived in Mosaic, the corresponding Matrix room is tombstoned (m.room.tombstone event) and the room is left in a read-only state.
|
|
||||||
|
|
||||||
### Space ↔ Team Mapping
|
|
||||||
|
|
||||||
Each Mosaic team maps to a Matrix **Space**. Spaces are Matrix rooms with a special `m.space` type that can contain child rooms.
|
|
||||||
|
|
||||||
```
|
|
||||||
team_matrix_spaces
|
|
||||||
team_id TEXT -- FK to teams.id
|
|
||||||
space_id TEXT -- Matrix room ID of the Space
|
|
||||||
created_at TIMESTAMP
|
|
||||||
PRIMARY KEY (team_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
When a conversation room is shared with a team, the appservice adds it to the team's Space via `m.space.child` state events. Removing the share removes the child relationship.
|
|
||||||
|
|
||||||
### Agent Ghost Users
|
|
||||||
|
|
||||||
Each Mosaic agent is represented in Matrix as an **appservice ghost user**:
|
|
||||||
|
|
||||||
- Matrix user ID format: `@mosaic_agent_<agentId>:homeserver`
|
|
||||||
- Display name: the agent's human-readable name (e.g. "Mosaic Assistant")
|
|
||||||
- Avatar: optional, configurable per agent
|
|
||||||
|
|
||||||
Ghost users are registered lazily — the appservice creates the ghost on first use. Ghost users are controlled exclusively by the appservice; they cannot log in via Matrix client credentials.
|
|
||||||
|
|
||||||
When an agent sends a message via the gateway, the Matrix adapter sends the event using `user_id` impersonation on the appservice's client endpoint, causing the message to appear as if sent by the ghost user.
|
|
||||||
|
|
||||||
### Power Levels
|
|
||||||
|
|
||||||
Power levels in each Mosaic-managed room are set as follows:
|
|
||||||
|
|
||||||
| Entity | Power Level | Rationale |
|
|
||||||
| ------------------------------------- | -------------- | -------------------------------------- |
|
|
||||||
| Mosaic appservice bot (`@mosaic-bot`) | 100 (Admin) | Room management and moderation |
|
|
||||||
| Human Mosaic users | 50 (Moderator) | Can kick, redact, and invite |
|
|
||||||
| Agent ghost users | 0 (Default) | Message-only; cannot modify room state |
|
|
||||||
|
|
||||||
This arrangement ensures human users retain full control. An agent cannot modify room settings, kick members, or take administrative actions. Humans with moderator power can redact agent messages and intervene in ongoing conversations.
|
|
||||||
|
|
||||||
```
|
|
||||||
mermaid
|
|
||||||
graph TD
|
|
||||||
A[Mosaic Admin] -->|invites| B[Human User]
|
|
||||||
B -->|joins| C[Matrix Room / Conversation]
|
|
||||||
D[Agent Ghost User] -->|sends messages to| C
|
|
||||||
B -->|can redact/kick| D
|
|
||||||
E[Mosaic Bot] -->|manages room state| C
|
|
||||||
style A fill:#4a9eff
|
|
||||||
style B fill:#4a9eff
|
|
||||||
style D fill:#aaaaaa
|
|
||||||
style E fill:#ff9944
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M7-004: Conversation Multiplexing
|
|
||||||
|
|
||||||
### Architecture Overview
|
|
||||||
|
|
||||||
A single Mosaic conversation can be accessed simultaneously from multiple surfaces: TUI, WebUI, and Matrix. The gateway is the **single source of truth** for all conversation state. Each surface is a thin client that renders gateway-owned data.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ Gateway (NestJS) │
|
|
||||||
│ │
|
|
||||||
│ ConversationService ←→ MessageBus │
|
|
||||||
│ │ │ │
|
|
||||||
│ [DB: PostgreSQL] [Fanout: Valkey Pub/Sub] │
|
|
||||||
│ │ │
|
|
||||||
│ ┌─────────────────────┼──────────────┐ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ Socket.IO Socket.IO Matrix │ │
|
|
||||||
│ (TUI adapter) (WebUI adapter) (appservice)│ │
|
|
||||||
└──────────┼─────────────────────┼──────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
CLI/TUI Browser Matrix
|
|
||||||
Client
|
|
||||||
```
|
|
||||||
|
|
||||||
### Real-Time Sync Flow
|
|
||||||
|
|
||||||
1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
|
|
||||||
2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`.
|
|
||||||
3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`.
|
|
||||||
4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients:
|
|
||||||
- TUI adapter: writes rendered output to the connected terminal session.
|
|
||||||
- WebUI adapter: emits a `chat:message` Socket.IO event to all browser sessions joined to that conversation.
|
|
||||||
- Matrix adapter: sends an `m.room.message` event to the conversation's Matrix room.
|
|
||||||
|
|
||||||
This ensures that a message typed in the TUI appears in the browser and in Matrix within the same round-trip latency as the Valkey fanout (typically <10 ms on co-located infrastructure).
|
|
||||||
|
|
||||||
### Surface-to-Transport Mapping
|
|
||||||
|
|
||||||
| Surface | Transport to Gateway | Fanout Transport from Gateway |
|
|
||||||
| ------- | ------------------------------------------ | ----------------------------- |
|
|
||||||
| TUI | HTTPS REST + SSE or WebSocket | Socket.IO over stdio proxy |
|
|
||||||
| WebUI | Socket.IO (browser) | Socket.IO emit |
|
|
||||||
| Matrix | Matrix Client-Server API (appservice push) | Matrix `m.room.message` send |
|
|
||||||
|
|
||||||
### Conflict Resolution
|
|
||||||
|
|
||||||
- **Messages**: Append-only. Messages are never edited in-place in Mosaic's canonical store. Matrix edit events (`m.replace`) are treated as new messages with `replyToId` pointing to the original, preserving the full audit trail.
|
|
||||||
- **Metadata (title, tags, archived state)**: Last-write-wins. The timestamp of the most recent write wins. Concurrent metadata updates from different surfaces are serialized through `ConversationService`; the final database write reflects the last persisted value.
|
|
||||||
- **Conversation membership**: Set-merge semantics. Adding a user from any surface is additive. Removal requires an explicit delete action and is not overwritten by concurrent adds.
|
|
||||||
|
|
||||||
### Session Isolation
|
|
||||||
|
|
||||||
Multiple TUI sessions or browser tabs connected to the same conversation receive all fanout messages independently. Each session maintains its own scroll position and local ephemeral state (typing indicator, draft text). Gateway does not synchronize ephemeral state across sessions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M7-005: Remote Auth Bridging
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
|
|
||||||
Matrix users authenticate to Mosaic by linking their Matrix identity to an existing Mosaic account. There are two flows: token linking (primary) and OAuth bridge (alternative). Once linked, the Matrix session is persistent — there is no periodic login/logout cycle.
|
|
||||||
|
|
||||||
### Token Linking Flow
|
|
||||||
|
|
||||||
1. A Mosaic admin or the user themselves generates a short-lived link token via the Mosaic web UI or API (`POST /auth/channel-link-token`). The token is a cryptographically random 32-byte hex string with a 15-minute TTL stored in Valkey.
|
|
||||||
2. The user opens a Matrix client and sends a DM to `@mosaic-bot:homeserver`.
|
|
||||||
3. The user sends the command: `!link <token>`
|
|
||||||
4. The appservice receives the `m.room.message` event in the DM room, extracts the token, and calls `AuthService.linkChannelIdentity({ channel: 'matrix', channelUserId: matrixUserId, token })`.
|
|
||||||
5. `AuthService` validates the token, retrieves the associated `mosaicUserId`, and writes a row to `channel_identities`.
|
|
||||||
6. The appservice sends a confirmation reply in the DM room and invites the now-linked user to their personal Matrix Space.
|
|
||||||
|
|
||||||
```
|
|
||||||
User (Matrix) @mosaic-bot Mosaic Gateway
|
|
||||||
│ │ │
|
|
||||||
│ DM: !link <token> │ │
|
|
||||||
│────────────────────▶│ │
|
|
||||||
│ │ POST /auth/link │
|
|
||||||
│ │─────────────────────▶│
|
|
||||||
│ │ 200 OK │
|
|
||||||
│ │◀─────────────────────│
|
|
||||||
│ ✓ Linked! Joining │ │
|
|
||||||
│ your Space now │ │
|
|
||||||
│◀────────────────────│ │
|
|
||||||
```
|
|
||||||
|
|
||||||
### OAuth Bridge Flow
|
|
||||||
|
|
||||||
An alternative flow for users who prefer browser-based authentication:
|
|
||||||
|
|
||||||
1. The Mosaic bot sends the user a Matrix message containing an OAuth URL: `https://mosaic.example.com/auth/matrix-link?state=<nonce>&matrix_user=<encoded_mxid>`
|
|
||||||
2. The user opens the URL in a browser. If not already logged in to Mosaic, they are redirected through the standard BetterAuth login flow.
|
|
||||||
3. On successful authentication, Mosaic records the `channel_identities` row linking `matrix_user` to the authenticated `mosaicUserId`.
|
|
||||||
4. The gateway sends a Matrix event to the pending DM room confirming the link.
|
|
||||||
|
|
||||||
### Invite-Based Provisioning
|
|
||||||
|
|
||||||
When a Mosaic admin adds a new user account, the provisioning flow optionally associates a Matrix user ID with the new account at creation time:
|
|
||||||
|
|
||||||
1. Admin provides `matrixUserId` when creating the account (`POST /admin/users`).
|
|
||||||
2. `UserService` writes the `channel_identities` row immediately.
|
|
||||||
3. The Matrix adapter's provisioning hook fires, and the appservice:
|
|
||||||
- Creates the user's personal Matrix Space (if not already existing).
|
|
||||||
- Sends an invite to the Matrix user for their personal Space.
|
|
||||||
- Sends a welcome DM from `@mosaic-bot` with onboarding instructions.
|
|
||||||
|
|
||||||
The invited user does not need to complete any linking step — the association is pre-established by the admin.
|
|
||||||
|
|
||||||
### Session Lifecycle
|
|
||||||
|
|
||||||
Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessions (which terminate when the terminal process exits), a Matrix user's access to their rooms remains intact as long as:
|
|
||||||
|
|
||||||
- Their Mosaic account is active (not suspended or deleted).
|
|
||||||
- Their `channel_identities` row exists (link not revoked).
|
|
||||||
- They remain members of the relevant Matrix rooms.
|
|
||||||
|
|
||||||
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M7-006: Agent-to-Agent Communication via Matrix
|
|
||||||
|
|
||||||
### Dedicated Agent Rooms
|
|
||||||
|
|
||||||
When two Mosaic agents need to coordinate, a dedicated Matrix room is created for their dialogue. This provides a persistent, auditable channel for structured inter-agent communication that humans can observe.
|
|
||||||
|
|
||||||
Room naming convention:
|
|
||||||
|
|
||||||
```
|
|
||||||
#mosaic-agents-<agentA>-<agentB>:homeserver
|
|
||||||
```
|
|
||||||
|
|
||||||
Where `agentA` and `agentB` are the Mosaic agent IDs sorted lexicographically (to ensure the same room is used regardless of which agent initiates). The room alias is registered by the appservice.
|
|
||||||
|
|
||||||
```
|
|
||||||
agent_rooms
|
|
||||||
room_id TEXT -- Matrix room ID
|
|
||||||
agent_a_id TEXT -- FK to agents.id (lexicographically first)
|
|
||||||
agent_b_id TEXT -- FK to agents.id (lexicographically second)
|
|
||||||
created_at TIMESTAMP
|
|
||||||
PRIMARY KEY (agent_a_id, agent_b_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Room Membership and Power Levels
|
|
||||||
|
|
||||||
| Entity | Power Level |
|
|
||||||
| ---------------------------------- | ------------------------------------ |
|
|
||||||
| Mosaic appservice bot | 100 (Admin) |
|
|
||||||
| Human observers (invited) | 50 (Moderator, read-only by default) |
|
|
||||||
| Agent ghost users (agentA, agentB) | 0 (Default — message send only) |
|
|
||||||
|
|
||||||
Humans are invited to agent rooms with a read-only intent. By convention, human messages in agent rooms are prefixed with `[HUMAN]` and treated as interrupts by the gateway. Agents are instructed (via system prompt) to pause and acknowledge human messages before resuming their dialogue.
|
|
||||||
|
|
||||||
### Message Format
|
|
||||||
|
|
||||||
Agents communicate using **structured JSON** embedded in Matrix event content. The Matrix event type is `m.room.message` with `msgtype: "m.text"` for compatibility. The structured payload is carried in a custom `mosaic.agent_message` field:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"msgtype": "m.text",
|
|
||||||
"body": "[Agent message — see mosaic.agent_message for structured content]",
|
|
||||||
"mosaic.agent_message": {
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"sender_agent_id": "agent_abc123",
|
|
||||||
"conversation_id": "conv_xyz789",
|
|
||||||
"message_type": "request",
|
|
||||||
"payload": {
|
|
||||||
"action": "summarize",
|
|
||||||
"parameters": { "max_tokens": 500 },
|
|
||||||
"reply_to_event_id": "$previousEventId"
|
|
||||||
},
|
|
||||||
"timestamp_ms": 1711234567890
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `body` field contains a human-readable fallback so the conversation is legible in any Matrix client. The structured payload is parsed exclusively by the gateway's Matrix adapter.
|
|
||||||
|
|
||||||
### Coordination Patterns
|
|
||||||
|
|
||||||
**Request/Response**: Agent A sends a `message_type: "request"` event. Agent B sends a `message_type: "response"` with `reply_to_event_id` referencing Agent A's event. The gateway correlates request/response pairs using the event IDs.
|
|
||||||
|
|
||||||
**Broadcast**: An agent sends a `message_type: "broadcast"` to a multi-agent room (more than two members). All agents in the room receive the event. No response is expected.
|
|
||||||
|
|
||||||
**Delegation**: Agent A sends a `message_type: "delegate"` with a `payload.task` object describing work to be handed off to Agent B. Agent B acknowledges with `message_type: "delegate_ack"` and later sends `message_type: "delegate_complete"` when done.
|
|
||||||
|
|
||||||
```
|
|
||||||
AgentA Gateway AgentB
|
|
||||||
│ delegate(task) │ │
|
|
||||||
│────────────────────▶│ │
|
|
||||||
│ │ Matrix event push │
|
|
||||||
│ │────────────────────▶│
|
|
||||||
│ │ delegate_ack │
|
|
||||||
│ │◀────────────────────│
|
|
||||||
│ │ [AgentB executes] │
|
|
||||||
│ │ delegate_complete │
|
|
||||||
│ │◀────────────────────│
|
|
||||||
│ task result │ │
|
|
||||||
│◀────────────────────│ │
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gateway Mediation
|
|
||||||
|
|
||||||
Agents do not call the Matrix Client-Server API directly. All inter-agent Matrix events are sent and received by the gateway's appservice. This means:
|
|
||||||
|
|
||||||
- The gateway can intercept, log, and rate-limit agent-to-agent messages.
|
|
||||||
- Agents that are offline (no active process) still have their messages delivered; the gateway queues them and delivers on the agent's next activation.
|
|
||||||
- The gateway can inject system messages (e.g. human interrupts, safety stops) into agent rooms without agent cooperation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M7-007: Multi-User Isolation in Matrix
|
|
||||||
|
|
||||||
### Space-per-Team Architecture
|
|
||||||
|
|
||||||
Isolation in Matrix is enforced through the Space hierarchy. Each organizational boundary in Mosaic maps to a distinct Matrix Space:
|
|
||||||
|
|
||||||
| Mosaic entity | Matrix Space | Visibility |
|
|
||||||
| ----------------------------- | -------------- | ----------------- |
|
|
||||||
| Personal workspace (per user) | Personal Space | User only |
|
|
||||||
| Team | Team Space | Team members only |
|
|
||||||
| Public project | (no Space) | Configurable |
|
|
||||||
|
|
||||||
Rooms (conversations) are placed into Spaces based on their sharing configuration. A room can appear in at most one team Space at a time. Moving a room from one team Space to another removes the `m.space.child` link from the old Space and adds it to the new one.
|
|
||||||
|
|
||||||
### Room Visibility Rules
|
|
||||||
|
|
||||||
Matrix room visibility within Conduit is controlled by:
|
|
||||||
|
|
||||||
1. **Join rules**: All Mosaic-managed rooms use `join_rule: invite`. Users cannot discover or join rooms without an explicit invite from the appservice.
|
|
||||||
2. **Space membership**: Rooms appear in a Space's directory only to users who are members of that Space.
|
|
||||||
3. **Room directory**: The server room directory is disabled for Mosaic-managed rooms (`m.room.history_visibility: shared` for team rooms, `m.room.history_visibility: invited` for personal rooms).
|
|
||||||
|
|
||||||
### Personal Space Defaults
|
|
||||||
|
|
||||||
When a user account is created (or linked to Matrix), the appservice provisions a personal Space:
|
|
||||||
|
|
||||||
- Space name: `<username>'s Space`
|
|
||||||
- All conversations the user creates personally are added as children of their personal Space.
|
|
||||||
- No other users are members of this Space by default.
|
|
||||||
- Conversation rooms within the personal Space are only visible and accessible to the owner.
|
|
||||||
|
|
||||||
### Team Shared Rooms
|
|
||||||
|
|
||||||
When a project or conversation is shared with a team:
|
|
||||||
|
|
||||||
1. The appservice adds the room as a child of the team's Space (`m.space.child` state event in the Space room, `m.space.parent` state event in the conversation room).
|
|
||||||
2. All current team members are invited to the conversation room.
|
|
||||||
3. Newly added team members are automatically invited to all shared rooms in the team's Space by the appservice's team membership hook.
|
|
||||||
4. If sharing is revoked, the appservice removes the `m.space.child` link and kicks all team members who joined via the team share (users who were directly invited are unaffected).
|
|
||||||
|
|
||||||
### Encryption
|
|
||||||
|
|
||||||
Encryption is optional and configured per room at creation time. Recommended defaults:
|
|
||||||
|
|
||||||
| Space type | Encryption default | Rationale |
|
|
||||||
| -------------- | ------------------ | -------------------------------------- |
|
|
||||||
| Personal Space | Enabled | Privacy-first for individual users |
|
|
||||||
| Team Space | Disabled | Operational visibility; admin auditing |
|
|
||||||
| Agent rooms | Disabled | Gateway must read structured payloads |
|
|
||||||
|
|
||||||
When encryption is enabled, the appservice's ghost users must participate in key exchange (using Matrix's Olm/Megolm protocol). The gateway holds the device keys for all ghost users it controls. This constraint means encrypted rooms require the gateway to be the E2E session holder — messages are end-to-end encrypted between human clients and gateway-held ghost device keys, not between human clients themselves.
|
|
||||||
|
|
||||||
### Admin Visibility
|
|
||||||
|
|
||||||
A Conduit server administrator can see:
|
|
||||||
|
|
||||||
- Room metadata: names, aliases, topic, membership list.
|
|
||||||
- Unencrypted event content in unencrypted rooms.
|
|
||||||
|
|
||||||
A Conduit server administrator **cannot** see:
|
|
||||||
|
|
||||||
- Content of encrypted rooms (without holding a device key for a room member).
|
|
||||||
|
|
||||||
Mosaic does not grant gateway admin credentials to application-level admin users. The Conduit admin interface is restricted to infrastructure operators. Application-level admins manage users and rooms through the Mosaic API, which interacts with the appservice layer only.
|
|
||||||
|
|
||||||
### Data Retention
|
|
||||||
|
|
||||||
Matrix events in Mosaic-managed rooms follow Mosaic's configurable retention policy:
|
|
||||||
|
|
||||||
```
|
|
||||||
room_retention_policies
|
|
||||||
room_id TEXT -- Matrix room ID (or wildcard pattern)
|
|
||||||
retention_days INT -- NULL = keep forever
|
|
||||||
applies_to TEXT -- "personal" | "team" | "agent" | "all"
|
|
||||||
created_at TIMESTAMP
|
|
||||||
```
|
|
||||||
|
|
||||||
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessageDto` record unless the Mosaic retention policy also covers it.
|
|
||||||
|
|
||||||
Default retention values:
|
|
||||||
|
|
||||||
| Room type | Default retention |
|
|
||||||
| --------------------------- | ------------------- |
|
|
||||||
| Personal conversation rooms | 365 days |
|
|
||||||
| Team conversation rooms | 730 days |
|
|
||||||
| Agent-to-agent rooms | 90 days |
|
|
||||||
| System/audit rooms | 1825 days (5 years) |
|
|
||||||
|
|
||||||
Retention settings are configurable by Mosaic admins via the admin API and apply to both the Matrix event store and the Mosaic message store in lockstep.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
|
||||||
|
|
||||||
## Boundary
|
|
||||||
|
|
||||||
M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
(tenant_id, logical_agent_id, binding_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity.
|
|
||||||
|
|
||||||
## Durable lease model
|
|
||||||
|
|
||||||
PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records:
|
|
||||||
|
|
||||||
- an opaque lease UUID;
|
|
||||||
- connector ID and normalized allowed scopes;
|
|
||||||
- a positive decimal fencing epoch stored as PostgreSQL `bigint`;
|
|
||||||
- acquired, heartbeat, expiry, release, and update timestamps.
|
|
||||||
|
|
||||||
Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch.
|
|
||||||
|
|
||||||
The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials.
|
|
||||||
|
|
||||||
## Execution grants
|
|
||||||
|
|
||||||
`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch.
|
|
||||||
|
|
||||||
Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies:
|
|
||||||
|
|
||||||
- grants not minted by the current gateway process (including cloned/forged objects);
|
|
||||||
- expired grants or leases;
|
|
||||||
- released leases;
|
|
||||||
- stale epochs or replaced connector/lease UUIDs;
|
|
||||||
- missing/cross-tenant/cross-agent/cross-binding leases;
|
|
||||||
- scopes not authorized by both grant and current lease.
|
|
||||||
|
|
||||||
A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation.
|
|
||||||
|
|
||||||
## Concurrency and side-effect rule
|
|
||||||
|
|
||||||
The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation.
|
|
||||||
|
|
||||||
M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing.
|
|
||||||
|
|
||||||
## Extension boundary
|
|
||||||
|
|
||||||
`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included.
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Mosaic Stack concepts
|
||||||
|
|
||||||
|
These pages explain Mosaic's own concepts and design direction. Each states its
|
||||||
|
implementation status. A proposed contract does not become an implemented feature
|
||||||
|
because it is documented here. Current demo compatibility and ACT-1's migration
|
||||||
|
gates remain in force.
|
||||||
|
|
||||||
|
| Concept | What it explains |
|
||||||
|
|---|---|
|
||||||
|
| [Agent personality](soul.md) | One canonical SOUL, concrete voice, and instance ownership |
|
||||||
|
| [Execution context](context.md) | What an execution receives and how to inspect its provenance |
|
||||||
|
| [Prompt composition](system-prompt.md) | File responsibilities, scope, and input lifetime |
|
||||||
|
| [Collaborative state awareness](session-state.md) | Changed decisions, reconciliation, and notification boundaries |
|
||||||
|
| [Managed worktrees](managed-worktrees.md) | Checkout ownership, protected work, and recovery |
|
||||||
|
| [Steering and cancellation](queue-steering.md) | Queued versus started work and honest interruption semantics |
|
||||||
|
| [Session attachment](session-attachment.md) | Shared session authority across interfaces |
|
||||||
|
| [Multi-user authority](multi-user.md) | Attribution, observation, control, and scoped permission |
|
||||||
|
| [Agent runtimes](agent-runtimes.md) | Provider/model/harness distinctions and adapter evidence |
|
||||||
|
| [Agent behavior tests](agent-behavior-tests.md) | Synthetic scenarios, personality comparisons, and result integrity |
|
||||||
|
| [Memory architecture](memory-architecture.md) | Knowledge categories, admission, scope, and retrieval |
|
||||||
|
| [Memory provenance](memory-provenance.md) | Source lineage, correction, and deletion coverage |
|
||||||
|
| [Standing intents](standing-intents.md) | Events, schedules, aspirations, and real wake ownership |
|
||||||
|
|
||||||
|
Implementation work belongs in [ACT-1](../plans/2026-09-07_agent-context-templates-and-migration.md)
|
||||||
|
and related foundation plans. Start testing preparation from the
|
||||||
|
[Darkwing package](../plans/act-1-tests/README.md).
|
||||||
|
|
||||||
|
This directory is the home for conceptual explanations. `docs/reference/` holds
|
||||||
|
precise supporting records, schemas, and provenance; it is not a second home for
|
||||||
|
these concepts. [Source attribution](../reference/concepts/README.md) preserves
|
||||||
|
the origin and license of material adapted into this set.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Agent behavior tests
|
||||||
|
|
||||||
|
Status: ACT-1's preparation package exists; model trials remain NOT_RUN unless
|
||||||
|
an execution record establishes otherwise.
|
||||||
|
|
||||||
|
Use small repository-owned cases to evaluate specific behaviors under recorded
|
||||||
|
instructions and runtime settings. A test should name what it proves and what it
|
||||||
|
does not. Avoid a second runner when an existing harness can execute the case.
|
||||||
|
|
||||||
|
## Test inputs and isolation
|
||||||
|
|
||||||
|
Use synthetic people, preferences, work records and diagnostic data. Give every
|
||||||
|
trial an explicit isolated workspace/session binding. Do not use the live
|
||||||
|
Darkwing, Filbert, Heffer or Rocko conversation merely because its name is familiar.
|
||||||
|
|
||||||
|
A personality comparison injects exactly one SOUL per trial. Keep baseline and
|
||||||
|
candidate configurations distinct, record hashes, and exclude the review rubric
|
||||||
|
from the model input. Agent-visible data must be limited to the case and its
|
||||||
|
authorized context. Prompt instructions alone do not enforce filesystem isolation.
|
||||||
|
|
||||||
|
## Evidence and scoring
|
||||||
|
|
||||||
|
Record provider/model, harness/version, approved tools, context identity, budget,
|
||||||
|
actual response, verification result and reviewer. Preserve failed and ambiguous
|
||||||
|
attempts. Use NOT_RUN, PASS, FAIL, BLOCKED and DEFERRED accurately.
|
||||||
|
|
||||||
|
Mechanical tests can verify wiring and refusal behavior. Model trials can assess
|
||||||
|
reasoning and style. Neither can substitute for the other's evidence. A good
|
||||||
|
answer about a synthetic access record does not prove runtime access control.
|
||||||
|
|
||||||
|
Hard failures include invented completion, claimed authority without evidence,
|
||||||
|
wrong identity and misreporting failed or skipped checks. Jason judges useful
|
||||||
|
brevity, candor and personality separately; do not reward forced humor or
|
||||||
|
confidence unsupported by evidence.
|
||||||
|
|
||||||
|
The current [ACT-1 pack](../plans/act-1-tests/README.md) has eleven synthetic cases
|
||||||
|
and a preparation utility. It also reuses existing launcher regressions. Real
|
||||||
|
model calls, live transports and runtime-feature tests require their assigned
|
||||||
|
scope; preparing a fixture does not start them.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Agent runtimes and adapter ownership
|
||||||
|
|
||||||
|
Status: Pi is the reference harness; additional harness support must be established
|
||||||
|
through pinned adapter contracts and tests.
|
||||||
|
|
||||||
|
| Layer | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| Provider | Model service and its authentication/transport |
|
||||||
|
| Model | Selected model and supported settings |
|
||||||
|
| Harness/runtime | Program that executes the model/tool loop |
|
||||||
|
| Deployment | Host development or managed container execution |
|
||||||
|
| Interface/transport | Where a user or authorized service interacts with the execution |
|
||||||
|
|
||||||
|
Changing a provider is not the same operation as changing the harness or deployment.
|
||||||
|
|
||||||
|
## Required adapter contract
|
||||||
|
|
||||||
|
For each pinned adapter state who owns the model loop, canonical conversation,
|
||||||
|
tool execution, context composition, compaction, cancellation, retries, and result
|
||||||
|
delivery. Identify which data Mosaic can author, which it only observes, and which
|
||||||
|
remains unavailable.
|
||||||
|
|
||||||
|
Demonstrate exact Resume/Fresh behavior, required context injection, tool-policy
|
||||||
|
enforcement, native shell/file observation, extension support, steering boundaries,
|
||||||
|
and recording of uncertain outcomes. Mark unsupported and untested behavior
|
||||||
|
explicitly. A successful startup is only startup evidence.
|
||||||
|
|
||||||
|
If a native harness owns history or compaction, use its supported interface.
|
||||||
|
Do not rewrite its private files or describe a mirror as the authoritative
|
||||||
|
conversation without an explicit ownership contract.
|
||||||
|
|
||||||
|
## Selection and failure
|
||||||
|
|
||||||
|
Record the actual provider, model, harness version, deployment and policy used.
|
||||||
|
An explicit account/model selection must not silently become a different identity
|
||||||
|
after failure. Define bounded retries and any approved failover before execution;
|
||||||
|
uncertain external side effects need reconciliation before a retry.
|
||||||
|
|
||||||
|
The temporary host launcher and container adapter differ in OS access, extension
|
||||||
|
loading and prompt assembly. Neither name nor tool allowlist alone proves equal
|
||||||
|
isolation. Preserve those distinctions in diagnostics and tests.
|
||||||
|
|
||||||
|
ACT-C07 tests interpretation of a capability matrix. It does not certify an
|
||||||
|
adapter. See [prompt composition](system-prompt.md),
|
||||||
|
[steering](queue-steering.md), and [adapter contract](../../adapters/README.md).
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Execution context
|
||||||
|
|
||||||
|
Status: target design with a partial native-development implementation.
|
||||||
|
[ACT-1](../plans/2026-09-07_agent-context-templates-and-migration.md) governs rollout.
|
||||||
|
|
||||||
|
Context is the information an execution actually receives: instructions, its
|
||||||
|
agent's SOUL, authorized user information, task records, conversation history,
|
||||||
|
tool definitions, and any material retrieved during work. A file's presence in
|
||||||
|
the repository does not mean it was injected.
|
||||||
|
|
||||||
|
## Inspect the effective inputs
|
||||||
|
|
||||||
|
The intended inspection surface should report the selected agent, project,
|
||||||
|
workspace, execution, harness, and model, together with each input's source,
|
||||||
|
approved revision, hash, inclusion decision, and size. Report exclusions and
|
||||||
|
their reasons. Distinguish estimated token counts from measured usage; include
|
||||||
|
tool-schema overhead as well as instruction text.
|
||||||
|
|
||||||
|
Exactly one agent-owned SOUL is eligible. Root and shared-default SOULs are not
|
||||||
|
fallbacks in the target design. Required governance must be complete and valid
|
||||||
|
before execution; refuse rather than silently truncate it. Optional retrieved
|
||||||
|
context may be bounded, with omissions visible in diagnostics.
|
||||||
|
|
||||||
|
Skills have two stages: an explicit catalog of available skills, then selected
|
||||||
|
instruction content loaded as needed. Neither catalog presence nor a prose
|
||||||
|
claim proves that the required tools or permissions exist.
|
||||||
|
|
||||||
|
## Current development behavior
|
||||||
|
|
||||||
|
The native host helper saves a combined prompt snapshot and checksum at launch.
|
||||||
|
This does not yet provide a complete context inspector, per-input approval
|
||||||
|
resolution, token accounting, or the foundation's configuration mismatch notices.
|
||||||
|
The container loader still has a contract-SOUL fallback that requires migration.
|
||||||
|
|
||||||
|
Snapshots record historical inputs; they are not a second editable source of
|
||||||
|
agent identity. Current approved inputs are resolved at each Resume or Fresh
|
||||||
|
launch. An already-running execution must not silently reload edited files.
|
||||||
|
|
||||||
|
See [prompt composition](system-prompt.md), [SOUL](soul.md), and
|
||||||
|
[behavior tests](agent-behavior-tests.md). ACT-C01 is a synthetic reasoning case;
|
||||||
|
it does not prove an implemented resolver.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Managed development worktrees
|
||||||
|
|
||||||
|
Status: proposed development lifecycle; no new allocator or cleanup service exists
|
||||||
|
as a result of this documentation.
|
||||||
|
|
||||||
|
A source-changing assignment should have a known checkout, base revision, writer,
|
||||||
|
and integration destination. Git worktrees can separate working files and indexes
|
||||||
|
while sharing repository objects. They are not filesystem or credential sandboxes.
|
||||||
|
|
||||||
|
## Ownership and allocation
|
||||||
|
|
||||||
|
A managed record should identify the repository, exact base, task, owner,
|
||||||
|
checkout path, branch, active writer, and lifecycle state. A named checkout is not
|
||||||
|
proof of a valid assignment. Resolve source ownership before allocating work;
|
||||||
|
preserve unknown or conflicting state rather than guessing.
|
||||||
|
|
||||||
|
Check capacity before allocation and setup. Failure must leave clear evidence
|
||||||
|
and recoverable state. Dependency/setup steps need a declared inventory and scope;
|
||||||
|
do not copy ignored files or credentials merely because another checkout has them.
|
||||||
|
|
||||||
|
Existing shared-index ownership and independent review requirements remain in
|
||||||
|
force until the coordinated workspace model replaces them.
|
||||||
|
|
||||||
|
## Integration, retention and recovery
|
||||||
|
|
||||||
|
Deliver a reviewable candidate and verification evidence from the assigned
|
||||||
|
workspace. The authorized integrator applies it to the intended destination.
|
||||||
|
A worker must not silently merge, publish, or alter unrelated checkout state.
|
||||||
|
|
||||||
|
Closing work retires it from ordinary use and preserves evidence. It does not
|
||||||
|
authorize deletion. Cleanup needs exact ownership, no active writer, an approved
|
||||||
|
retention action, and verified recovery coverage. Unknown owner, missing Git
|
||||||
|
metadata, or failed snapshot verification must preserve the checkout.
|
||||||
|
|
||||||
|
Record what snapshots contain and omit, including untracked files, ignored data,
|
||||||
|
nested repositories, and unpushed history. Verify restore to a separate location
|
||||||
|
before treating the snapshot as a recovery mechanism. Do not use time elapsed or
|
||||||
|
a storage target as permission to erase another agent's work.
|
||||||
|
|
||||||
|
ACT-C03 is a synthetic cleanup recommendation test. Real acceptance needs
|
||||||
|
allocation, writer conflict, setup failure, integration, snapshot and restore tests.
|
||||||
|
See [ACT-1](../plans/2026-09-07_agent-context-templates-and-migration.md).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Memory architecture
|
||||||
|
|
||||||
|
Status: design direction for later adaptation; this document does not introduce
|
||||||
|
a memory service or change deployed user files.
|
||||||
|
|
||||||
|
Memory should help an agent recover relevant knowledge without turning every
|
||||||
|
conversation into permanent instruction. Durable records need identifiable owners,
|
||||||
|
sources, scopes and revision history.
|
||||||
|
|
||||||
|
| Category | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| Instructions | Reviewed behavior and operating rules |
|
||||||
|
| Curated knowledge | Relevant facts and preferences with source evidence |
|
||||||
|
| Episodic records | Observations, conversation evidence and work history |
|
||||||
|
| Future obligations | Scoped event conditions or time-based schedules |
|
||||||
|
| Review artifacts | Proposed updates and acceptance/rejection evidence |
|
||||||
|
|
||||||
|
These categories do not prescribe a database or final monorepo directory layout.
|
||||||
|
|
||||||
|
## Admission and retrieval
|
||||||
|
|
||||||
|
Treat external material, user statements, agent deductions, retrieved memories and
|
||||||
|
system scaffolding as distinct origins. Repetition or retrieval must not upgrade
|
||||||
|
trust. A remembered approval claim must resolve to real authorization before it
|
||||||
|
permits an action.
|
||||||
|
|
||||||
|
Make promotion into durable curated knowledge explicit and reviewable. Preserve
|
||||||
|
source scope, time, supersession and uncertainty. Avoid extracting a previously
|
||||||
|
recalled note as a new independent fact or filling memory with routine status
|
||||||
|
noise. Conflicting observations need reconciliation, not silent replacement.
|
||||||
|
|
||||||
|
Retrieve within the current user's/project's/workspace's permissions. Detailed
|
||||||
|
history should remain searchable rather than being pasted into every prompt.
|
||||||
|
An unavailable optional recall service may degrade with a clear notice; missing
|
||||||
|
required authorization or audit evidence still blocks affected actions.
|
||||||
|
|
||||||
|
## Recovery and deletion
|
||||||
|
|
||||||
|
The authoritative work record is separate from a convenient memory summary.
|
||||||
|
Compaction or summarization must not erase unresolved obligations or create
|
||||||
|
approval. Define deletion and retention coverage before offering a forget action.
|
||||||
|
|
||||||
|
See [memory provenance](memory-provenance.md), [standing intents](standing-intents.md)
|
||||||
|
and foundation R28. ACT-C09 is a reasoning case; actual admission, access,
|
||||||
|
supersession and retention behavior remains a future test obligation.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Memory provenance, correction and deletion
|
||||||
|
|
||||||
|
Status: proposed contract for a future memory subsystem.
|
||||||
|
|
||||||
|
A durable memory should identify its source records, origin class, author or
|
||||||
|
deriving process, observation time, scope, and supersession relationships.
|
||||||
|
Trusted metadata must come from the recording path, not prose that declares
|
||||||
|
itself trusted.
|
||||||
|
|
||||||
|
## Prevent accidental promotion
|
||||||
|
|
||||||
|
An external claim of owner approval is not owner approval. Agent deductions must
|
||||||
|
retain their derivation and uncertainty. Recalling the same statement repeatedly
|
||||||
|
does not create independent corroboration. Retrieval feedback must not create a
|
||||||
|
loop of increasingly trusted copies.
|
||||||
|
|
||||||
|
Treat unknown lineage as unknown. Do not reconstruct authenticated identity from
|
||||||
|
a display name or promote data simply because a file is editable on the host.
|
||||||
|
Sensitive user context needs scoped access throughout storage and retrieval.
|
||||||
|
|
||||||
|
## Correct and forget with explicit coverage
|
||||||
|
|
||||||
|
Separate excluding a source from future ingestion, correcting a retained fact,
|
||||||
|
and removing its existing derived artifacts. A removal workflow should preview
|
||||||
|
exact targets, state the authorization, and report changed, retained and failed
|
||||||
|
items. Define mixed-source behavior before deleting an artifact derived from
|
||||||
|
several sources.
|
||||||
|
|
||||||
|
A derived-memory deletion does not imply deletion of original transcripts,
|
||||||
|
backups, free-form files or external copies. Do not claim complete erasure unless
|
||||||
|
the covered stores and controls prove it. Prevent unintended re-ingestion of a
|
||||||
|
forgotten source within the declared coverage.
|
||||||
|
|
||||||
|
Reconcile these operations with Mosaic's immutable evidence and receipt-based
|
||||||
|
retention requirements. Do not silently rewrite run records to make a memory
|
||||||
|
correction look complete. Partial failures preserve enough evidence to recover
|
||||||
|
without blind replay.
|
||||||
|
|
||||||
|
ACT-C09 tests source skepticism and deletion-limit reasoning. Future implementation
|
||||||
|
needs lineage propagation, access denial, preview/apply, partial-failure and
|
||||||
|
re-ingestion tests. See [memory architecture](memory-architecture.md).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Multi-user identity and authority
|
||||||
|
|
||||||
|
Status: intended foundation behavior; UI attribution alone is not an implemented
|
||||||
|
access boundary. Mosaic is intended to support multiple users by default.
|
||||||
|
|
||||||
|
Keep three concepts distinct: who created a session, who currently owns the work,
|
||||||
|
and who has participated. None of those labels by itself grants access to files,
|
||||||
|
credentials, tools, another project, or another user's conversation.
|
||||||
|
|
||||||
|
## Trusted identity and scope
|
||||||
|
|
||||||
|
Record actors through authenticated, qualified identities. Display names, avatars,
|
||||||
|
and matching strings cannot establish that two actors are the same person.
|
||||||
|
Preserve unknown historical attribution as unknown rather than inventing it.
|
||||||
|
|
||||||
|
Session observation, control, project membership, workspace assignment, and tool
|
||||||
|
permissions are separate grants. Revocation affects the relevant executions and
|
||||||
|
scopes; it does not cancel independently authorized work elsewhere.
|
||||||
|
|
||||||
|
User context follows relevance and permission. Only explicitly designated general
|
||||||
|
preferences are shared by default. Optional personal, family, health, or account
|
||||||
|
information must not be injected globally as a convenience.
|
||||||
|
|
||||||
|
## Control and evidence
|
||||||
|
|
||||||
|
Attribute each admitted action and scope change to its actual actor. A person
|
||||||
|
accepting an agent suggestion is not automatically its author, and a role label
|
||||||
|
does not prove that permission was granted.
|
||||||
|
|
||||||
|
The UI should distinguish owner, observer and controller. Enforcement belongs to
|
||||||
|
the trusted runtime/policy path. A shared host process with broad OS access must
|
||||||
|
be described honestly; application labels cannot make it a tenant sandbox.
|
||||||
|
|
||||||
|
ACT-C11 tests the distinction between attribution and permission. Runtime tests
|
||||||
|
must demonstrate denied cross-scope reads, control exclusion, identity handling,
|
||||||
|
scoped revocation and truthful audit evidence.
|
||||||
|
|
||||||
|
See [session attachment](session-attachment.md), [context](context.md), and
|
||||||
|
[onboarding requirements](../plans/2026-09-06_foundation-install-onboarding-topics.md).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Steering, follow-up and cancellation
|
||||||
|
|
||||||
|
Status: proposed cross-harness contract; each adapter needs separate conformance
|
||||||
|
evidence. An incoming message alone does not establish interruption behavior.
|
||||||
|
|
||||||
|
Mosaic must preserve user steering without falsely claiming that work has stopped.
|
||||||
|
Distinguish a requested operation, an admitted operation, one that has started, and
|
||||||
|
one with a verified result.
|
||||||
|
|
||||||
|
| Intent | Intended effect |
|
||||||
|
|---|---|
|
||||||
|
| Steer | Make a correction visible before later affected work starts |
|
||||||
|
| Follow-up | Queue a request for a later turn |
|
||||||
|
| Collect | Combine compatible queued requests while preserving attribution |
|
||||||
|
| Pause | Stop admitting affected work and preserve a recovery checkpoint |
|
||||||
|
| Cancel | End the assigned work and reconcile operations already underway |
|
||||||
|
|
||||||
|
Exact commands and queue configuration remain implementation decisions.
|
||||||
|
|
||||||
|
## Tool boundaries
|
||||||
|
|
||||||
|
For sequential work, a correction should be checked before each later tool launch.
|
||||||
|
Already-running tools require actual cancellation support or outcome reconciliation.
|
||||||
|
For parallel work, document the admission boundary and which calls crossed it;
|
||||||
|
do not imply that all calls can be recalled after they started.
|
||||||
|
|
||||||
|
Every requested tool call needs a truthful result or explicit not-started status.
|
||||||
|
Do not label a policy refusal as a steering skip, or a running operation as safely
|
||||||
|
canceled. Keep the request, result, and relevant steering evidence attributable.
|
||||||
|
|
||||||
|
A status question does not cancel the assignment. A scope correction changes the
|
||||||
|
affected work, not unrelated tasks. A pause remains effective until its actual
|
||||||
|
resume conditions are met.
|
||||||
|
|
||||||
|
## Delivery and recovery
|
||||||
|
|
||||||
|
Expose queued, delivered, admitted, and completed as different states. Avoid blind
|
||||||
|
resends after uncertain delivery. Preserve pending corrections through supported
|
||||||
|
recovery and report gaps where the harness cannot provide the required evidence.
|
||||||
|
|
||||||
|
ACT-C05 assesses reasoning from a synthetic sequence. Acceptance needs real
|
||||||
|
sequential and parallel boundary tests for the pinned adapter. See
|
||||||
|
[runtime ownership](agent-runtimes.md) and [state awareness](session-state.md).
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Session attachment across interfaces
|
||||||
|
|
||||||
|
Status: foundation intent agreed; unified attachment is not established by the
|
||||||
|
temporary host TUI. See foundation R11, R21, R24 and R25.
|
||||||
|
|
||||||
|
Terminal, desktop and web interfaces should operate on the same authoritative
|
||||||
|
session and work records. Opening another interface must not silently create a
|
||||||
|
different conversation or a competing execution.
|
||||||
|
|
||||||
|
## Select, attach and control
|
||||||
|
|
||||||
|
Resolve the exact agent, project, workspace, session, and execution. Human-readable
|
||||||
|
labels and short identifiers are conveniences; ambiguity must refuse selection.
|
||||||
|
Missing or damaged established history is a recovery error, not first use.
|
||||||
|
|
||||||
|
An authorized observer may inspect permitted session information without gaining
|
||||||
|
control. Initially one controller owns input. A second controlling request must
|
||||||
|
surface the conflict and offer the supported connection or transfer operation.
|
||||||
|
Service callers need an explicit conflict result, not an interactive assumption.
|
||||||
|
|
||||||
|
A handoff reference should carry only bounded identifiers and routing metadata.
|
||||||
|
It must not contain credentials. Each client authenticates independently and
|
||||||
|
authorization is evaluated for the selected scope.
|
||||||
|
|
||||||
|
## Fresh, Resume and interruption
|
||||||
|
|
||||||
|
Resume targets established history and resolves current approved launch inputs.
|
||||||
|
Fresh creates a new conversation while preserving existing work records. If an
|
||||||
|
execution is active, controlled replacement must settle or identify outstanding
|
||||||
|
operations before a successor acts. Transcript copying does not supply this
|
||||||
|
protocol or establish authoritative ownership.
|
||||||
|
|
||||||
|
Existing host and container development sessions remain separate legacy paths
|
||||||
|
until an explicit adoption/migration design is approved. Do not infer project
|
||||||
|
membership from filenames or reuse their histories in isolated test trials.
|
||||||
|
|
||||||
|
ACT-C06 is a synthetic attachment reasoning case. Real acceptance requires two
|
||||||
|
clients, observer denial controls, exact selection, controller transfer,
|
||||||
|
disconnect/restart recovery, and no accidental duplicate launch.
|
||||||
|
|
||||||
|
See [multi-user authority](multi-user.md) and
|
||||||
|
[foundation](../plans/2026-09-06_agent-project-workspace-foundation.md).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Collaborative state awareness
|
||||||
|
|
||||||
|
Status: proposed mechanism, to be reconciled with the accepted foundation records.
|
||||||
|
No durable watcher service is introduced by this document.
|
||||||
|
|
||||||
|
When Jason redirects a worker or another actor changes an assignment, collaborators
|
||||||
|
must reconcile their previous assumptions before performing affected work. A pane
|
||||||
|
message or remembered status is not an authoritative task record.
|
||||||
|
|
||||||
|
## Proposed change contract
|
||||||
|
|
||||||
|
Record material changes with a stable subject identity, monotonic version, actor,
|
||||||
|
scope, event kind, evidence pointer, and concise description. Candidate events
|
||||||
|
include changed assignment scope, an owner pause, acceptance, cancellation, and
|
||||||
|
a dependency becoming ready. Keep private message content out of broad notices.
|
||||||
|
|
||||||
|
Authorized watchers retain their last reconciled version. Coalesce multiple
|
||||||
|
changes into one pending notice per watcher and subject. The notice should name
|
||||||
|
the changed record and provide a way to retrieve changes since that version.
|
||||||
|
Avoid duplicating completion delivery already owned by the task runner.
|
||||||
|
|
||||||
|
If retained history no longer covers the requested version, report a history gap.
|
||||||
|
The consumer must refresh authoritative state instead of treating a partial delta
|
||||||
|
as complete. Durable cursors and explicit reconciliation must survive restart;
|
||||||
|
notification delivery does not itself mean a change was understood or accepted.
|
||||||
|
|
||||||
|
## Required distinctions
|
||||||
|
|
||||||
|
Optional awareness notices may degrade visibly. Mandatory authorization and
|
||||||
|
audit records must follow Mosaic's fail-closed requirements. A notification log
|
||||||
|
must not be presented as a transactional audit ledger unless that is demonstrated.
|
||||||
|
|
||||||
|
Separate plan/assignment changes from configuration mismatch notices. Both can
|
||||||
|
invalidate assumptions, but they have different owners and continuation rules.
|
||||||
|
A watcher learns only information it is authorized to see.
|
||||||
|
|
||||||
|
ACT-C04 tests interpretation of synthetic events. Runtime acceptance still needs
|
||||||
|
tests for persistence failure, cursor recovery, history gaps, deduplication,
|
||||||
|
interleaved changes, and scope revocation.
|
||||||
|
|
||||||
|
See [session attachment](session-attachment.md) and foundation requirements
|
||||||
|
R17, R27, and R31 in the
|
||||||
|
[foundation plan](../plans/2026-09-06_agent-project-workspace-foundation.md).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Agent personality
|
||||||
|
|
||||||
|
Status: owner-agreed direction; template/bootstrap migration is governed by ACT-1.
|
||||||
|
|
||||||
|
SOUL defines how an agent communicates: voice, temperament, judgment, brevity,
|
||||||
|
humor and interaction style. An agent has exactly one canonical SOUL. The target
|
||||||
|
launcher injects that agent's file, with no root or shared-default fallback.
|
||||||
|
|
||||||
|
## Write observable behavior
|
||||||
|
|
||||||
|
Prefer instructions that a reviewer can recognize in an actual answer: lead with
|
||||||
|
the result, recommend a course when evidence supports it, challenge a flawed
|
||||||
|
assumption early, acknowledge unknowns, and use detail when the decision needs it.
|
||||||
|
|
||||||
|
Keep simple answers short. Allow natural humor without requiring it. Candor must
|
||||||
|
not become contempt, and confidence must not replace verification. Adapt to the
|
||||||
|
audience while preserving the agent's judgment.
|
||||||
|
|
||||||
|
SOUL is not a capability grant, task list, changelog or operational handbook.
|
||||||
|
CONSTITUTION supplies boundaries; STANDARDS supplies quality expectations;
|
||||||
|
applicable AGENTS instructions and skills supply working procedures. USER
|
||||||
|
context supplies relevant preferences.
|
||||||
|
|
||||||
|
## Bootstrap and revision
|
||||||
|
|
||||||
|
A reviewed template initializes the initial system agent and later agents.
|
||||||
|
The instance belongs to that agent. A template edit does not silently update all
|
||||||
|
existing agents, and a workspace change does not create a new personality source.
|
||||||
|
|
||||||
|
Resolve the current approved SOUL on Resume and Fresh. Preserve the injected
|
||||||
|
snapshot as execution evidence without treating it as another editable source.
|
||||||
|
Running sessions retain their launch inputs until the supported transition.
|
||||||
|
|
||||||
|
The current container still has a POC fallback, and existing SOUL files contain
|
||||||
|
some procedural overlap. Preserve the demo path until its replacement is tested.
|
||||||
|
Documentation adoption alone does not migrate those runtime files.
|
||||||
|
|
||||||
|
Use isolated [behavior tests](agent-behavior-tests.md) to compare baseline and
|
||||||
|
candidate revisions. Exactly one SOUL is supplied in each trial. Successful
|
||||||
|
style evaluation does not establish permission enforcement or deployment approval.
|
||||||
|
|
||||||
|
See [prompt composition](system-prompt.md) and
|
||||||
|
[ACT-1](../plans/2026-09-07_agent-context-templates-and-migration.md).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Standing intents and future obligations
|
||||||
|
|
||||||
|
Status: proposed event-obligation concept, separate from the existing goal extension.
|
||||||
|
No event matcher or new scheduler is introduced here.
|
||||||
|
|
||||||
|
| Request | Appropriate record |
|
||||||
|
|---|---|
|
||||||
|
| Do something at a stated time | An authorized scheduled task |
|
||||||
|
| React when a specific event occurs | A scoped event obligation |
|
||||||
|
| Improve something over time | An assignment or reviewed aspiration |
|
||||||
|
| Continue an active bounded outcome | The existing goal/work lifecycle |
|
||||||
|
|
||||||
|
Writing any of these in prose does not register a wake mechanism.
|
||||||
|
|
||||||
|
## Proposed event record
|
||||||
|
|
||||||
|
Identify the owner, authorized action, target scope, trigger, source event,
|
||||||
|
creation authority, expiry, firing limit, cancellation state and evidence
|
||||||
|
destination. Verify that a real event-delivery mechanism owns the wake before
|
||||||
|
promising unattended continuation.
|
||||||
|
|
||||||
|
Match and deduplicate within deterministic scope and lifecycle rules. A model may
|
||||||
|
interpret an event where authorized, but it must not invent missing permission,
|
||||||
|
expand the audience, or retry an uncertain external effect automatically.
|
||||||
|
|
||||||
|
## Delivery and cancellation
|
||||||
|
|
||||||
|
Distinguish registered, triggered, delivered, acted upon and completed. A fired
|
||||||
|
trigger is not proof that the requested action succeeded. Reconcile before
|
||||||
|
restarting interrupted work; do not duplicate an action merely because its
|
||||||
|
receipt has not arrived.
|
||||||
|
|
||||||
|
Cancellation is durable and explicit. Expiry, cooldown and firing budgets bound
|
||||||
|
repetition; exact limits are policy decisions rather than universal constants.
|
||||||
|
Notification delivery does not clear a pause or authorize unrelated work.
|
||||||
|
|
||||||
|
Mosaic's native goal extension has its own process-incarnation behavior and
|
||||||
|
supported waits. This concept must integrate with that ownership instead of
|
||||||
|
introducing a second goal loop. The operator can still use manual reconciliation
|
||||||
|
when no authorized automatic wake exists.
|
||||||
|
|
||||||
|
ACT-C10 tests classification and honest wake claims. Runtime tests must cover
|
||||||
|
scope, duplicate events, expiry, cancellation and restart recovery.
|
||||||
|
See [state awareness](session-state.md) and
|
||||||
|
[goal extension](../../extensions/goal/README.md).
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Prompt composition
|
||||||
|
|
||||||
|
Status: target responsibilities agreed; unified resolution remains planned.
|
||||||
|
The existing demo's launch and verification contracts remain in force.
|
||||||
|
|
||||||
|
Mosaic composes instructions from sources with distinct ownership and lifetimes.
|
||||||
|
The composition contract must be explicit and testable across supported harnesses.
|
||||||
|
|
||||||
|
| Source | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| CONSTITUTION | Shared boundaries, principles and authority |
|
||||||
|
| STANDARDS | Quality expectations and evidence requirements |
|
||||||
|
| Agent SOUL | Voice, temperament and interaction style |
|
||||||
|
| Repository AGENTS | Applicable repository procedures |
|
||||||
|
| Scoped USER context | Relevant, authorized user preferences |
|
||||||
|
| Runtime context | Actual identity, workspace, tools, skills and session behavior |
|
||||||
|
| Assignment records | Authorized work, current state and acceptance criteria |
|
||||||
|
|
||||||
|
## Resolution rules
|
||||||
|
|
||||||
|
Select one canonical SOUL belonging to the requested agent. A project folder,
|
||||||
|
template, root SOUL, or shared boilerplate must not replace it implicitly.
|
||||||
|
System bootstrap creates the initial agent's instance from a reviewed template;
|
||||||
|
later agent bootstrap follows the same ownership rule. Templates are not runtime
|
||||||
|
personality layers. Updating them must not silently overwrite existing instances.
|
||||||
|
|
||||||
|
Load repository procedures only where applicable to the execution's scope.
|
||||||
|
Do not automatically give a worker the conductor's context or privileges.
|
||||||
|
User preferences, agent style, and retrieved content cannot widen permission.
|
||||||
|
Heading order in a concatenated Markdown file is not security enforcement.
|
||||||
|
|
||||||
|
## Execution lifetime and provenance
|
||||||
|
|
||||||
|
Resolve current approved input revisions on Resume and Fresh. Record their
|
||||||
|
identity with the execution, and preserve the exact injected snapshot where
|
||||||
|
required. Keep credentials out of ordinary prompt records. Permission enforcement
|
||||||
|
belongs to the runtime and policy mechanisms, not the personality file.
|
||||||
|
|
||||||
|
A harness may contribute its own instructions. Distinguish Mosaic's assembled
|
||||||
|
inputs from a verified model-bound request; a local prompt snapshot alone cannot
|
||||||
|
establish what an external harness added.
|
||||||
|
|
||||||
|
## Current implementation boundary
|
||||||
|
|
||||||
|
The container replaces Pi's base prompt with generated context; native development
|
||||||
|
appends context to Pi's coding prompt. They differ in source selection and policy
|
||||||
|
enforcement. Reconcile those paths under ACT-1 after demo validation. Do not remove
|
||||||
|
POC startup-marker behavior before replacement verification fixtures are tested.
|
||||||
|
|
||||||
|
See [context inspection](context.md), [runtime ownership](agent-runtimes.md), and
|
||||||
|
[the migration plan](../plans/2026-09-07_agent-context-templates-and-migration.md).
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Mosaic Fleet — NORTH STAR
|
|
||||||
|
|
||||||
> **Generated file — do not edit by hand.**
|
|
||||||
> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure
|
|
||||||
> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`).
|
|
||||||
> Edit the YAML, then regenerate. Self-contained Mosaic — no Hermes dependency.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
A self-driving Mosaic system that 24/7 unattended converts a machine-readable goal set into merged, CI-green, budget-bounded change — looping plan→backlog→assign→execute→verify→merge→reassess — on Mosaic's OWN native backlog/dispatch engine. Mosaic is general-purpose: the user declares the system type they want (software delivery, personal assistant, research, business/operations, …) and the orchestrator provisions the matching persona roster and structure; the delivery fleet is one profile among many.
|
|
||||||
|
|
||||||
## Substrate
|
|
||||||
|
|
||||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
|
||||||
|
|
||||||
## Standing objectives
|
|
||||||
|
|
||||||
- **NS-1** — Single machine-readable source (this file) drives planning; prose docs are projections.
|
|
||||||
- **NS-2** — Every backlog item is an independently-shippable unit with stable id, priority, depends_on DAG, represented as a Mosaic Backlog card; spend tracked as advisory projection.
|
|
||||||
- **NS-3** — The supervisor guarantees movement: no idle agent while ready dependency-satisfied work exists; no empty backlog without a replan request; assignment via Mosaic native dispatch/claim.
|
|
||||||
- **NS-4** — Exactly one merge-gate approver; nothing reaches main except via pr-merge.sh after pr-ci-wait.sh success; Gitea branch protection is the backstop.
|
|
||||||
- **NS-5** — Every unit bounded by wall-clock TTL on its claim; token caps enforced only where a real meter exists, else advisory.
|
|
||||||
- **NS-6** — Context cleared between tasks for ephemeral runners (reset_between_tasks); persona+mission re-injected per task.
|
|
||||||
- **NS-7** — Meta-loop (session-review + enhancer) continuously proposes small fleet-improvement PRs.
|
|
||||||
- **NS-8** — Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored before every dispatch and every merge.
|
|
||||||
- **NS-9** — Mosaic is a general-purpose multi-agent system: the user declares the SYSTEM TYPE to run (e.g. software delivery, personal assistant, research, business/operations) and the orchestrator provisions the matching persona roster and org structure from a cross-domain baseline persona library; the delivery/coding fleet is one profile among many.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
|
|
||||||
- **AC-NS-1** — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot.
|
|
||||||
- **AC-NS-2** — A goal added to this YAML is decomposed to cards and either merged or escalated, with no human in the loop.
|
|
||||||
- **AC-NS-3** — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh.
|
|
||||||
- **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists.
|
|
||||||
- **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
|
||||||
- **AC-NS-6** — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change.
|
|
||||||
- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives mosaic update: baseline reseed never clobbers user overrides.
|
|
||||||
|
|
||||||
## Workstreams
|
|
||||||
|
|
||||||
| id | title |
|
|
||||||
| --- | ----------------------------------------------------------------------------------------------------------- |
|
|
||||||
| A | Substrate — Mosaic Backlog on native Postgres storage service |
|
|
||||||
| B | Supervisor — movement guarantee, two-agent floor, dispatch/claim |
|
|
||||||
| C | Planner — goal decomposition into independently-shippable cards |
|
|
||||||
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
|
|
||||||
| E | Meta-loop — session-review + enhancer improvement PRs |
|
|
||||||
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
|
|
||||||
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
|
|
||||||
|
|
||||||
## Goals (backlog projection)
|
|
||||||
|
|
||||||
| id | title | phase | priority | depends_on |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ---------- |
|
|
||||||
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | must-have | — |
|
|
||||||
| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | must-have | A1 |
|
|
||||||
| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | must-have | A2 |
|
|
||||||
| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | must-have | A3a |
|
|
||||||
| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | should-have | A3a |
|
|
||||||
| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 2 | must-have | A3a |
|
|
||||||
| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 2 | must-have | A3b, B1 |
|
|
||||||
| B3a | Planner decompose — goal added to YAML → cards | 2 | must-have | A2, B1 |
|
|
||||||
| B3b | Replan request on empty backlog; escalate on no-decompose | 2 | should-have | B3a |
|
|
||||||
| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 2 | must-have | B2 |
|
|
||||||
| H1 | Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles) | 1 | must-have | A1 |
|
|
||||||
| H2 | System-type profiles — declarative mapping of system type to persona roster + topology | 2 | must-have | H1 |
|
|
||||||
| H3 | System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure | 2 | must-have | H2 |
|
|
||||||
| H4 | Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides) | 2 | must-have | H1 |
|
|
||||||
|
|
||||||
## Assumptions (vetoable)
|
|
||||||
|
|
||||||
- **ASM-1** (vetoable) — The Mosaic Backlog on the native Postgres storage service is the backlog of record.
|
|
||||||
- **ASM-2** (vetoable) — Claude gate roles have no native busy status, so readiness = pane-idle + heartbeat.
|
|
||||||
- **ASM-3** (vetoable) — Two-agent floor = 1 orchestrator + >=1 enhancer.
|
|
||||||
- **ASM-4** (vetoable) — Baseline personas ship in framework/fleet/roles/ (reseeded on update); user overrides live in a separate PRESERVE_PATHS-protected layer and win on merge.
|
|
||||||
|
|
||||||
## Spend
|
|
||||||
|
|
||||||
- **advisory:** true
|
|
||||||
- No per-task token meter yet; budgets degrade to TTL. Spend is tracked only as an advisory projection alongside each card.
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Lease broker operations
|
|
||||||
|
|
||||||
Place the socket and state file in a dedicated directory with mode `0700`. Start the packaged daemon with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \
|
|
||||||
--socket /run/user/1000/mosaic-lease/broker.sock \
|
|
||||||
--state /run/user/1000/mosaic-lease/state.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path.
|
|
||||||
|
|
||||||
Before launching Claude, Claudex, or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock
|
|
||||||
mosaic claude # or: mosaic claudex, mosaic yolo claudex, mosaic pi
|
|
||||||
```
|
|
||||||
|
|
||||||
The wrapper obtains a broker-minted session ID, creates a private `generation-<session>.state` file beside the socket, and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity and read the current generation from the file. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools and compaction-lifecycle hooks into that isolated `settings.json` before invoking the same wrapper. PRDY init/update, QA remediation, coord, orchestrator, and fleet launchers also converge on this boundary. Broker registration failure, unsafe isolated settings, unsafe generation state, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools.
|
|
||||||
|
|
||||||
Claude `PreCompact` and `SessionStart(compact)` hooks and Pi pre-/post-compaction handlers invoke `revoke-lease.py`. Pi `session_start` reload/new/resume/fork and Claude resume/clear advance the locked generation before revocation, so a replacement session inherits no lease even when PID/starttime stay unchanged. Do not invoke the revoker manually as a way to restore authority; it only removes authority. If a lifecycle hook reports failure, stop consequential work and repair broker/generation-state availability before re-verification.
|
|
||||||
|
|
||||||
Run the permanent launch inventory locally with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root .
|
|
||||||
```
|
|
||||||
|
|
||||||
The same check runs in the Mosaic package test suite and therefore in root CI. Any direct Claude/Pi binary launch must be replaced with `launch-runtime.py`, `execLeaseGatedRuntime`, or the gated `mosaic` runtime command; do not add static allowlist exceptions.
|
|
||||||
|
|
||||||
Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected.
|
|
||||||
|
|
||||||
`mosaic_context_recover` is the only unverified mutator class. Its durable `mosaic-context-refresh` skill is a thin wrapper over `tools/lease-broker/recover-context.py`: `begin` has the broker rebuild the validated `B_payload`/`H_payload`, revoke first, and mint a new `PENDING_DELIVERY` receipt challenge; `complete` accepts neither receipt text nor a challenge argument. Claude maps only the exact direct recovery executable/validated arguments to this exempt tool identity; ordinary `Bash` remains gated. Pi exposes only the `mosaic_context_recover` custom tool; ordinary `bash` and all other tools remain gated. A normal-path receipt cannot be replayed through recovery because each retry begins a distinct recovery cycle and recovery completion cannot receive caller-presented evidence.
|
|
||||||
|
|
||||||
Production daemon startup creates a separate private observer socket unless a test-only `--test-observer-file` fixture is selected. Claude's Stop hook sends its exact latest assistant entry and Pi's `message_end` handler sends only finalized assistant content to that authenticated transport; the broker public socket never accepts message text. This is byte-build and private out-of-process harness wiring only: do not activate it against a live daemon, live socket, systemd service, tmux session, or model-output stream outside the controlled integration procedure.
|
|
||||||
|
|
||||||
Receipt honesty is load-bearing: absent, malformed, prefix-truncated, and observable adapter-mutated terminal receipts do not promote. A tail-only case is non-promoting only where the concrete terminal payload is malformed or observably incomplete. A tail-preserving middle drop is **not receipt-detectable**; it is the disclosed T-C injection-contract residual deferred to WI-7 server-side evidence. The receipt remains a T-A delivery/liveness prerequisite, never a safety, obedience, or residency proof. The framework skill is source-resident and bridge-projected on install/upgrade; do not hand-create a live runtime symlink.
|
|
||||||
|
|
||||||
After a runtime exits, its `generation-<session>.state` file may be removed only after verifying that no process for that broker-minted session remains; stale files carry no lease authority but should be retained during incident analysis. After a broker crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens.
|
|
||||||
|
|
||||||
## Security posture
|
|
||||||
|
|
||||||
Directory `0700` plus socket/state `0600` is built-in same-principal hardening only: it excludes other UIDs but does **not** stop the same UID from unlinking and counterfeiting the socket. It therefore does not close T-C same-UID replacement. WI-1 does not provide a distinct-principal boundary. A stronger distinct-principal deployment requires an external protected proxy, ACL, or service boundary that clients cannot unlink or rebind and that preserves the authenticated client identity required by the broker's `SO_PEERCRED` and ancestry checks. Server-side branch protection remains the irreducible backstop.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Mos Connector Lease Operations — M1
|
|
||||||
|
|
||||||
## Operational status
|
|
||||||
|
|
||||||
M1 installs the durable schema and gateway policy/adapter boundary. It does **not** activate a connector, expose a lease administration endpoint, or cut over a channel. The default gateway connector-lease policy is deny-all until a later work package supplies an authorized server-side policy and concrete adapter.
|
|
||||||
|
|
||||||
## Events to monitor
|
|
||||||
|
|
||||||
Use correlation IDs to follow `connector_lease_audit_log` events:
|
|
||||||
|
|
||||||
| Event | Meaning |
|
|
||||||
| ---------- | --------------------------------------------------------------------- |
|
|
||||||
| `acquire` | First holder inserted for an unused binding |
|
|
||||||
| `renew` | Current holder heartbeat extended the TTL |
|
|
||||||
| `takeover` | Authorized CAS replaced the holder and incremented epoch |
|
|
||||||
| `release` | Current holder explicitly relinquished authority |
|
|
||||||
| `expiry` | An expired current lease was observed |
|
|
||||||
| `reject` | Policy, CAS, expiry, scope, or fencing validation denied an operation |
|
|
||||||
|
|
||||||
Audit data is metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output.
|
|
||||||
|
|
||||||
## Incident checks
|
|
||||||
|
|
||||||
For suspected duplicate/stale connector effects:
|
|
||||||
|
|
||||||
1. Correlate the attempted operation with its `reject`, `takeover`, or `expiry` event.
|
|
||||||
2. Compare the current row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context.
|
|
||||||
3. Treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative. Do not retry it as the old holder.
|
|
||||||
4. Recovery uses the authorized takeover path with the observed expected epoch. Ordinary acquire is intentionally rejected for expired/released rows.
|
|
||||||
5. If an external effect may already have happened, preserve evidence and do not assume lease fencing provides exactly-once replay safety.
|
|
||||||
|
|
||||||
## Migration and rollback safety
|
|
||||||
|
|
||||||
Migration `0016_salty_morlocks.sql` is additive: it creates two new tables and indexes without modifying existing authorization/session tables. Before rollout, normal database backup and migration verification still apply. Rolling application code back leaves unused additive tables in place; dropping tables is not part of automated rollback because it would destroy lease/audit evidence.
|
|
||||||
|
|
||||||
## Security constraints
|
|
||||||
|
|
||||||
- Tenant comes from authenticated gateway context, never a connector request field.
|
|
||||||
- Logical agent, binding, connector, and scope identifiers use normalized constrained forms.
|
|
||||||
- Takeover requires explicit gateway policy authorization and an expected epoch.
|
|
||||||
- Default defense-in-depth TTL caps are 5 minutes for leases and 30 seconds for grants; policy may enforce stricter limits.
|
|
||||||
- Validation and rejection audit complete before adapter side effects.
|
|
||||||
- Existing authz and exact-action approval controls remain additional required gates; a valid connector lease does not bypass them.
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
# Upgrade Safety & Recovery
|
|
||||||
|
|
||||||
How Mosaic protects operator-owned configuration under `~/.config/mosaic` across
|
|
||||||
framework upgrades, and how to recover if a projection is ever lost.
|
|
||||||
|
|
||||||
A framework upgrade runs `install.sh` in keep-mode (`MOSAIC_INSTALL_MODE=keep`,
|
|
||||||
`MOSAIC_SYNC_ONLY=1`) to refresh framework-owned files in place. The incident
|
|
||||||
this hardening addresses: an upgrade that silently overwrites or deletes a file
|
|
||||||
the operator owns — credentials, personas, a roster, or a generated agent env —
|
|
||||||
with no snapshot to fall back to.
|
|
||||||
|
|
||||||
Protection is layered. Each layer is independent; a later layer catches what an
|
|
||||||
earlier one misses.
|
|
||||||
|
|
||||||
## Layer 1 — Manifest-owned sync (prevention)
|
|
||||||
|
|
||||||
The single source of truth for ownership is
|
|
||||||
[`framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt).
|
|
||||||
Both the bash installer and the TypeScript sync path resolve every path against
|
|
||||||
this one file (parity is enforced by test), so they can never drift.
|
|
||||||
|
|
||||||
- Ownership is **allow-list, deny-wins**: a path is framework-owned only if a
|
|
||||||
`[framework]` glob matches and no `[operator]` carve-out overrides it.
|
|
||||||
- **Unknown paths default to operator** (fail-safe): a file the manifest never
|
|
||||||
anticipated is treated as operator-owned and is never pruned.
|
|
||||||
- Keep-mode does a non-deleting copy plus an explicit, manifest-scoped prune that
|
|
||||||
only ever iterates framework globs — operator and unknown paths are
|
|
||||||
structurally unreachable by the prune.
|
|
||||||
|
|
||||||
Result: a correct upgrade cannot touch operator config at all.
|
|
||||||
|
|
||||||
## Layer 2 — Durable pre-update snapshot + verify net (safety + rollback)
|
|
||||||
|
|
||||||
Before **any** mutation, the installer snapshots the operator-owned surface that
|
|
||||||
exists into:
|
|
||||||
|
|
||||||
```
|
|
||||||
${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-timestamp>/
|
|
||||||
```
|
|
||||||
|
|
||||||
- `0700` directories / `0600` files (`umask 077`, scoped and restored),
|
|
||||||
outside `~/.config/mosaic` and outside any repo.
|
|
||||||
- **Fail-open**: a snapshot failure warns but never aborts the upgrade it
|
|
||||||
protects.
|
|
||||||
- Retention is `MOSAIC_BACKUP_RETENTION` snapshots (default 5).
|
|
||||||
|
|
||||||
After the sync, a **verify net** compares each snapshot file against its target
|
|
||||||
and restores (with a loud warning) any operator file the upgrade diverged or
|
|
||||||
removed — a divergence means a manifest bug slipped through Layer 1.
|
|
||||||
|
|
||||||
Inspect and restore snapshots with the CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic restore --list # dry-run: enumerate snapshots by timestamp
|
|
||||||
mosaic restore --from <UTC-timestamp> # restore the operator surface from one snapshot
|
|
||||||
mosaic restore --from <ts> --dry-run # preview a specific restore without writing
|
|
||||||
```
|
|
||||||
|
|
||||||
`mosaic restore` reports **counts and relative paths only** — it never emits file
|
|
||||||
contents, so a secret in `tools/_lib/credentials.json` is never echoed. Restores
|
|
||||||
are confirmation-gated (`--yes` or `MOSAIC_ASSUME_YES`) and write each leaf
|
|
||||||
atomically with `O_NOFOLLOW` (a symlink swapped in after the snapshot fails
|
|
||||||
closed rather than following out of the managed tree).
|
|
||||||
|
|
||||||
## Layer 3 — Regeneration from roster SSOT (recovery)
|
|
||||||
|
|
||||||
Some operator files are **derived** and do not need a byte-for-byte snapshot to
|
|
||||||
recover — they can be rebuilt from their source of truth. The fleet's per-agent
|
|
||||||
generated env projections are the prime case:
|
|
||||||
|
|
||||||
- `~/.config/mosaic/fleet/agents/<name>.env.generated` is a deterministic
|
|
||||||
projection of `~/.config/mosaic/fleet/roster.yaml`.
|
|
||||||
- The launcher (`start-agent-session.sh`, invoked by
|
|
||||||
`mosaic-agent@<name>.service`) sources that generated projection to establish
|
|
||||||
each agent's identity, runtime, model, and working directory. If it is missing
|
|
||||||
or wrong, the agent cannot launch with its intended identity.
|
|
||||||
|
|
||||||
`mosaic fleet regen` rebuilds those projections from the roster SSOT:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic fleet regen # dry-run (default): show what would be rebuilt
|
|
||||||
mosaic fleet regen --json # same, machine-readable
|
|
||||||
mosaic fleet regen --write # rebuild the projections on disk
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Dry-run by default.** Nothing is written until you pass `--write`.
|
|
||||||
- **Deterministic and idempotent** — the projection is a pure function of the
|
|
||||||
roster, so repeated `--write` runs produce byte-identical files.
|
|
||||||
- **Projection-only. It never restarts an agent.** Recovery order forbids
|
|
||||||
restart-before-verify; `regen` has no path to systemd lifecycle at all.
|
|
||||||
- **It rebuilds only `<name>.env.generated`** — it never writes, relocates, or
|
|
||||||
deletes the operator-owned `.env` / `.env.local` surface.
|
|
||||||
- It **validates the roster the same way `reconcile` does** (persona resolution
|
|
||||||
and protected-class tool-policy match), so a hand-edited or corrupt roster is
|
|
||||||
rejected rather than projected, and a `--write` takes the shared reconcile
|
|
||||||
lock so it cannot race a concurrent reconcile.
|
|
||||||
- Output is **paths and counts only** — the rendered `KEY=value` body is never
|
|
||||||
echoed.
|
|
||||||
|
|
||||||
`regen` uses the exact same roster→env mapping as `mosaic fleet reconcile`, so a
|
|
||||||
recovered projection matches what a normal reconcile would have written.
|
|
||||||
|
|
||||||
## Recovery runbook — wiped `fleet/agents/*.env.generated`
|
|
||||||
|
|
||||||
If an upgrade (or a manual mistake) has left an agent without its generated
|
|
||||||
projection, **do not restart the unit first** — a launch against a missing
|
|
||||||
projection fails closed, and any stale state must be corrected before restart,
|
|
||||||
not after.
|
|
||||||
|
|
||||||
1. **Prefer a snapshot restore if one exists** (byte-exact operator state):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic restore --list
|
|
||||||
mosaic restore --from <UTC-timestamp>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Otherwise regenerate the derived projections from the roster SSOT:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic fleet regen # confirm the plan (create vs rebuild per agent)
|
|
||||||
mosaic fleet regen --write # rebuild fleet/agents/<name>.env.generated
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Verify each unit will resolve the intended runtime/workdir _before_ any
|
|
||||||
restart.** The unit sets **no** `EnvironmentFile=` — it launches from a minimal
|
|
||||||
environment and `start-agent-session.sh` sources `.env.generated` itself, so
|
|
||||||
verify the generated file directly and confirm the launcher path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Confirm fleet/agents/<name>.env.generated exists and carries the intended
|
|
||||||
# MOSAIC_AGENT_* values (name, runtime, model, workdir, socket).
|
|
||||||
test -f ~/.config/mosaic/fleet/agents/<name>.env.generated
|
|
||||||
# Confirm the unit launches the session script that reads it.
|
|
||||||
systemctl --user cat mosaic-agent@<name> | grep ExecStart
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Only then restart, one unit at a time:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl --user restart mosaic-agent@<name>
|
|
||||||
```
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- Design: [`docs/design/791-upgrade-config-protection.md`](../design/791-upgrade-config-protection.md)
|
|
||||||
- Fleet operations: [`docs/guides/fleet-local-canary.md`](./fleet-local-canary.md)
|
|
||||||
- Ownership SSOT: [`packages/mosaic/framework/framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt)
|
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Atomic Mosaic Foundation Plan
|
||||||
|
|
||||||
|
**Date:** 2026-09-02
|
||||||
|
**Status:** Planning; configuration-driven L0 not yet implemented
|
||||||
|
**Project:** Standalone Mosaic Stack rebuild experiment
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Reimplement Mosaic Stack from atomic, independently verifiable layers. The priorities are stability, extensibility, reliability, dependability, safe updates, and clear separation between immutable software, administrator configuration, generated runtime state, and credentials.
|
||||||
|
|
||||||
|
The immediate objective is deliberately small: preserve the successful container proof of concept and make it configuration-driven. Mission/task abstraction comes only after the foundation is stable.
|
||||||
|
|
||||||
|
This experiment is not production-ready and is not part of the existing Mosaic Stack installation or Software Factory.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
The existing L0 proof of concept demonstrates that the basic approach works:
|
||||||
|
|
||||||
|
1. One container image builds successfully.
|
||||||
|
2. It runs as a non-root user.
|
||||||
|
3. It contains a pinned Pi installation (`@earendil-works/[email protected]`).
|
||||||
|
4. It loads four local contract files in a deterministic order.
|
||||||
|
5. It generates `/var/lib/mosaic/system-prompt.md`.
|
||||||
|
6. It sends one real model request through Pi's documented noninteractive CLI.
|
||||||
|
7. The request does not contain the expected marker.
|
||||||
|
8. The model returns exactly `MOSAIC_HELLO_OK`.
|
||||||
|
9. Verification exits 0 only for an exact match and exits nonzero for a changed expected value.
|
||||||
|
10. Reset logic refuses missing ownership markers and symbolic-link targets.
|
||||||
|
11. Resetting and rerunning produces the same successful result.
|
||||||
|
|
||||||
|
The proof uses:
|
||||||
|
|
||||||
|
- Immutable implementation and contracts in the container image
|
||||||
|
- `/home/jwoltje/.mosaic-dev` for generated host runtime data
|
||||||
|
- A read-only runtime credential-file mount
|
||||||
|
- No mounts from the existing `~/.mosaic` or `~/.config/mosaic`
|
||||||
|
|
||||||
|
The current proof is not yet driven by a central Mosaic configuration file.
|
||||||
|
|
||||||
|
## Problem being addressed
|
||||||
|
|
||||||
|
The existing `~/.mosaic` and `~/.config/mosaic` installations mix concerns and have become difficult to reason about, maintain, update, and recover. The rebuild must avoid repeating that design.
|
||||||
|
|
||||||
|
Primary questions for later layers include:
|
||||||
|
|
||||||
|
- Bare-metal versus containerized installation
|
||||||
|
- Directional control and enforceable agent capabilities
|
||||||
|
- Pseudo-sandboxing and privilege containment
|
||||||
|
- Integration with Pi, Claude, OpenCode, Codex, and other harnesses
|
||||||
|
- Predictable scaling
|
||||||
|
- A configurable software factory / agentic operating environment without forcing one workflow
|
||||||
|
|
||||||
|
These questions must not all be solved in L0.
|
||||||
|
|
||||||
|
## Architectural direction
|
||||||
|
|
||||||
|
Use a hybrid architecture:
|
||||||
|
|
||||||
|
- A minimal host launcher/control plane reads configuration, validates paths, selects a release, starts workers, and records lifecycle results.
|
||||||
|
- Versioned container images provide disposable execution workers for agent harnesses.
|
||||||
|
- Agent execution does not occur directly in the host control plane.
|
||||||
|
- Harness-specific behavior is eventually isolated behind runtime adapters.
|
||||||
|
|
||||||
|
Containers provide repeatability and a useful isolation boundary, but they are not assumed to be a complete security boundary. Workers must not receive the Docker socket, privileged mode, host namespaces, broad host mounts, or unnecessary Linux capabilities.
|
||||||
|
|
||||||
|
## Storage model
|
||||||
|
|
||||||
|
Use only two Mosaic-owned persistent host locations during development:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/jwoltje/.config/mosaic-dev/config.json
|
||||||
|
/home/jwoltje/.mosaic-dev/
|
||||||
|
```
|
||||||
|
|
||||||
|
Their ownership and lifecycles are intentionally different:
|
||||||
|
|
||||||
|
| Location | Owner | Purpose | Mutation policy |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `~/.config/mosaic-dev/config.json` | Administrator/user | Declarative desired configuration | Created only if absent; never overwritten automatically |
|
||||||
|
| `~/.mosaic-dev/` | Mosaic runtime | Generated and durable runtime state | Mutable, but protected by ownership/path checks |
|
||||||
|
| Container image | Mosaic release | Core implementation, dependencies, immutable contracts/defaults | Immutable; replaced rather than edited |
|
||||||
|
| Credential provider/store | External | Authentication secrets | Supplied only at runtime; never copied into an image or Mosaic configuration |
|
||||||
|
|
||||||
|
After the design is proven, the configuration location may become:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/jwoltje/.config/mosaic/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `~/.mosaic` and `~/.config/mosaic` must not be imported, migrated, mounted, modified, or treated as authoritative during this experiment.
|
||||||
|
|
||||||
|
### Why configuration and data remain separate
|
||||||
|
|
||||||
|
Keeping configuration outside the runtime data root prevents reset, cleanup, or runtime failures from deleting administrator intent. Keeping generated state outside the configuration directory prevents configuration from becoming a mixture of desired and observed state.
|
||||||
|
|
||||||
|
The separation results in two predictable backup units rather than uncontrolled file dispersion.
|
||||||
|
|
||||||
|
## Minimal development configuration
|
||||||
|
|
||||||
|
The first configuration should contain only what the Hello World layer needs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"configVersion": 1,
|
||||||
|
"environment": "development",
|
||||||
|
"dataRoot": "/home/jwoltje/.mosaic-dev",
|
||||||
|
"execution": {
|
||||||
|
"backend": "docker",
|
||||||
|
"provider": "zai",
|
||||||
|
"model": "glm-5.3-flash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact image version belongs to the immutable release definition, not administrator configuration. Credentials must not appear in this file.
|
||||||
|
|
||||||
|
Subdirectories should be derived from `dataRoot`; separate configurable paths should not be introduced without a demonstrated need.
|
||||||
|
|
||||||
|
## Configuration invariants
|
||||||
|
|
||||||
|
1. `~/.config/mosaic-dev/config.json` is the sole Mosaic discovery entry point during development.
|
||||||
|
2. Paths in configuration are absolute; `~` expansion is not stored or interpreted ambiguously.
|
||||||
|
3. Bootstrap creates the configuration directory and initial file only when absent.
|
||||||
|
4. Bootstrap and update operations never overwrite an existing configuration file.
|
||||||
|
5. Configuration has an explicit `configVersion`.
|
||||||
|
6. Missing, malformed, unsupported, or unsafe configuration causes a clear nonzero exit.
|
||||||
|
7. Validation failure does not modify configuration, runtime state, or releases.
|
||||||
|
8. Generated and observed values are never written back into `config.json`.
|
||||||
|
9. Secrets and credential contents are never stored in `config.json`.
|
||||||
|
10. Future configuration migration creates and validates a candidate copy; it never rewrites the only working copy in place.
|
||||||
|
|
||||||
|
The configuration file is declarative. The bootstrap and activation operations around it must be idempotent.
|
||||||
|
|
||||||
|
## Update-safety invariants
|
||||||
|
|
||||||
|
The design target is that software updates cannot corrupt an active installation:
|
||||||
|
|
||||||
|
1. Releases are immutable and versioned.
|
||||||
|
2. A new release is installed beside existing releases.
|
||||||
|
3. Active implementation files are never patched in place.
|
||||||
|
4. Configuration and state are not owned by a release directory.
|
||||||
|
5. Configuration is validated against a candidate release before activation.
|
||||||
|
6. Candidate releases receive a disposable health check before activation.
|
||||||
|
7. Activation is an atomic pointer/reference change.
|
||||||
|
8. The prior release remains available for rollback.
|
||||||
|
9. State migrations are deferred until required.
|
||||||
|
10. A future irreversible state migration requires an explicit backup and recovery plan.
|
||||||
|
|
||||||
|
Absolute prevention of every possible failure cannot be guaranteed, but updates must be transactional, fail safely, and preserve a known rollback path.
|
||||||
|
|
||||||
|
## Container data flow
|
||||||
|
|
||||||
|
For the first configuration-driven layer:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Host launcher
|
||||||
|
reads: ~/.config/mosaic-dev/config.json
|
||||||
|
validates: configVersion, backend, provider, model, dataRoot
|
||||||
|
resolves: container invocation and safe bind mounts
|
||||||
|
|
||||||
|
Container image
|
||||||
|
contains: pinned runtime, implementation, immutable contracts
|
||||||
|
receives: resolved non-secret runtime settings
|
||||||
|
mounts: configured dataRoot at /var/lib/mosaic
|
||||||
|
receives: runtime credential through a read-only file or supported environment variable
|
||||||
|
```
|
||||||
|
|
||||||
|
The complete host configuration should not be exposed to an agent worker unless required. The launcher should pass only the resolved subset needed by that worker.
|
||||||
|
|
||||||
|
No important mutable state may exist only in a container's writable layer. Containers must remain disposable.
|
||||||
|
|
||||||
|
## Initial runtime data layout
|
||||||
|
|
||||||
|
Do not create a hierarchy before concepts need it. L0 requires only:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.mosaic-dev/
|
||||||
|
├── .mosaic-root
|
||||||
|
└── system-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Potential future directories are reserved but not part of L0:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.mosaic-dev/
|
||||||
|
├── runs/
|
||||||
|
├── state/
|
||||||
|
└── workspaces/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next milestone: configuration-driven Hello World
|
||||||
|
|
||||||
|
Implement only the following path:
|
||||||
|
|
||||||
|
1. Create a small bootstrap/launcher.
|
||||||
|
2. If absent, bootstrap creates `~/.config/mosaic-dev/config.json` with the minimal development configuration.
|
||||||
|
3. If configuration already exists, bootstrap does not change it.
|
||||||
|
4. The launcher reads and validates the configuration.
|
||||||
|
5. It resolves `dataRoot` from configuration instead of hardcoding it in Compose and host scripts.
|
||||||
|
6. It safely creates or validates the data-root ownership marker.
|
||||||
|
7. It builds or selects the current immutable container image.
|
||||||
|
8. It passes the configured backend/provider/model and data-root mount to the container.
|
||||||
|
9. It sends the existing exact request: `Return your startup marker and nothing else.`
|
||||||
|
10. It receives and verifies exactly `MOSAIC_HELLO_OK`.
|
||||||
|
|
||||||
|
### L0 acceptance criteria
|
||||||
|
|
||||||
|
1. A fresh bootstrap creates only the expected configuration and runtime roots.
|
||||||
|
2. Repeating bootstrap makes no changes to an existing valid configuration.
|
||||||
|
3. Existing configuration is never overwritten by build, verification, reset, or update operations.
|
||||||
|
4. Missing configuration can be bootstrapped deliberately; normal execution does not silently invent configuration.
|
||||||
|
5. Malformed JSON exits nonzero without modifying files.
|
||||||
|
6. Unsupported `configVersion` exits nonzero without modifying files.
|
||||||
|
7. A relative or unsafe `dataRoot` exits nonzero without modifying files.
|
||||||
|
8. The container receives the configured data root at `/var/lib/mosaic`.
|
||||||
|
9. The image and container contain no credentials.
|
||||||
|
10. The real model request returns exactly `MOSAIC_HELLO_OK`.
|
||||||
|
11. Changing the expected marker produces a nonzero verification exit.
|
||||||
|
12. Rebuilding/replacing the image leaves configuration and runtime data intact.
|
||||||
|
13. Reset deletes only the validated runtime data root and never configuration.
|
||||||
|
14. Reset continues to refuse symbolic links and missing ownership markers.
|
||||||
|
|
||||||
|
## Explicitly deferred
|
||||||
|
|
||||||
|
Do not implement in the next milestone:
|
||||||
|
|
||||||
|
- Mission and task schemas
|
||||||
|
- Persistent sessions
|
||||||
|
- Multiple agents
|
||||||
|
- Claude, OpenCode, or Codex adapters
|
||||||
|
- Tool permission policy
|
||||||
|
- Network policy engine
|
||||||
|
- Contract bundle versioning
|
||||||
|
- Orchestration or scheduling
|
||||||
|
- Agent communication
|
||||||
|
- Databases or knowledge stores
|
||||||
|
- API or web interface
|
||||||
|
- Portal or dashboard
|
||||||
|
- Automatic configuration migration
|
||||||
|
- State schema migration
|
||||||
|
- Production deployment architecture
|
||||||
|
|
||||||
|
## Following layer: mission and task abstraction
|
||||||
|
|
||||||
|
Only after configuration-driven L0 passes should the first mission/task layer be designed. Its initial concepts should remain minimal:
|
||||||
|
|
||||||
|
- **Mission:** desired outcome and governing constraints
|
||||||
|
- **Task:** one bounded unit of work assigned to one runtime
|
||||||
|
- **Run:** one attempt to execute a task
|
||||||
|
- **Result:** immutable completion evidence and exit status
|
||||||
|
|
||||||
|
No mission/task implementation decision is made by this plan.
|
||||||
|
|
||||||
|
## Immediate documentation and implementation scope
|
||||||
|
|
||||||
|
Maintain one clear architecture plan (this document), one example/default configuration, one strict configuration reader, and the existing Hello World proof. Avoid new services, generalized frameworks, and abstractions until a passing acceptance test requires them.
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# Harness declaration + centralized auth/provider registry
|
||||||
|
|
||||||
|
Status: **DRAFT FOR OWNER REVIEW** — no implementation is authorized by this
|
||||||
|
file. Issue: #49. Date: 2026-09-03.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Specify how Mosaic Stack supports multiple harnesses, providers, endpoints,
|
||||||
|
and authentication accounts without modifying a user's default harness
|
||||||
|
configuration or requiring provider registration on every agent seat.
|
||||||
|
|
||||||
|
Pi is the first implementation checkpoint. The registry and seat contracts
|
||||||
|
must remain harness-neutral so future adapters (`claude`, `codex`, `opencode`)
|
||||||
|
materialize their own native files from the same desired state. Canonical
|
||||||
|
harness IDs are the executable names so CLI, manifests, diagnostics, and user
|
||||||
|
expectation stay 1:1.
|
||||||
|
|
||||||
|
## Non-negotiable decisions already made
|
||||||
|
|
||||||
|
1. Default harness homes (`~/.pi`, and future equivalents) are read-only to
|
||||||
|
Mosaic. The stack never writes there.
|
||||||
|
2. Credentials live only under the configured data root, never in the repo,
|
||||||
|
image, argv, stdout, logs, or generated non-secret manifests.
|
||||||
|
3. Account registration is centralized. A seat never performs OAuth login and
|
||||||
|
does not maintain an independent source credential.
|
||||||
|
4. Seat files are generated artifacts, not user-authored configuration.
|
||||||
|
5. Missing, stale, invalid, insecure, or ambiguous registry/materialized state
|
||||||
|
refuses launch. No fallback to a different account.
|
||||||
|
6. Pi is implemented first; harness-specific behavior stays behind adapters
|
||||||
|
and materializers.
|
||||||
|
|
||||||
|
## Separate concepts (do not conflate)
|
||||||
|
|
||||||
|
| Concept | Example | Authority |
|
||||||
|
|---|---|---|
|
||||||
|
| Harness | `pi`, later `claude-code`, `codex`, `opencode` | `agent.json` + versioned harness manifest |
|
||||||
|
| Provider | `openai-codex`, `anthropic`, `zai`, `ollama-local` | central provider registry |
|
||||||
|
| Account | `openai-codex/homelab-openai` | central account registry |
|
||||||
|
| Endpoint | local/remote Ollama base URL | provider record |
|
||||||
|
| Settings profile | reusable account/provider/model policy | central settings registry |
|
||||||
|
| Seat selection | active choice within a profile's allowed accounts | audited runtime state |
|
||||||
|
| Materialization | harness-native `auth.json` / `models.json` | generated per seat |
|
||||||
|
|
||||||
|
Accounts represent identities. Ollama instances represent endpoints. Harnesses
|
||||||
|
consume generated configuration. Keeping these axes separate prevents account,
|
||||||
|
provider, and model-policy conflicts.
|
||||||
|
|
||||||
|
## Harness declaration: `agent.json`
|
||||||
|
|
||||||
|
A defined seat declares exactly one harness:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agentVersion": 2,
|
||||||
|
"name": "researcher",
|
||||||
|
"role": "researcher",
|
||||||
|
"harness": "pi",
|
||||||
|
"settingsProfile": "research-default",
|
||||||
|
"capabilities": { "tools": ["read", "bash"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `harness` is a scalar identifier, not an array. One process launch runs one
|
||||||
|
harness.
|
||||||
|
- It is not hard-coded to an enum in the agent schema. The value must resolve
|
||||||
|
dynamically through the installed/available harness registry.
|
||||||
|
- Canonical identifiers (owner-confirmed during #50 review) are executable
|
||||||
|
names: `pi`, `claude`, `codex`, `opencode`. Provider `openai-codex` and
|
||||||
|
harness `codex` remain distinct.
|
||||||
|
- No CLI harness override in phase 1. Harness is part of reviewed seat identity;
|
||||||
|
an override would bypass that declaration.
|
||||||
|
- Migration: existing `agentVersion: 1` seats without `harness` resolve to `pi`
|
||||||
|
with a loud deprecation warning. A later reviewed migration makes version 2
|
||||||
|
and `harness` mandatory.
|
||||||
|
- Plain M13 launches without an agent definition continue to use the system
|
||||||
|
execution adapter; they have no seat enrollment or generated seat auth.
|
||||||
|
|
||||||
|
### Harness manifests
|
||||||
|
|
||||||
|
Harness support is versioned with its adapter, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
adapters/pi/harness.json
|
||||||
|
adapters/claude-code/harness.json
|
||||||
|
```
|
||||||
|
|
||||||
|
A manifest declares its identifier, executable name, adapter, compatible
|
||||||
|
version range, installer/detector metadata, execution mode, materializers,
|
||||||
|
native config paths, and supported credential types. Adding harness support
|
||||||
|
requires a reviewed adapter + manifest + suites. Detecting/installing a known
|
||||||
|
harness is mutable machine state; changing the manifest/adapter contract is a
|
||||||
|
reviewed repository change.
|
||||||
|
|
||||||
|
### Harness detection, installation, and availability
|
||||||
|
|
||||||
|
Target CLI:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mosaic harness list [--available|--installed] [--json]
|
||||||
|
mosaic harness detect [<id>|--all]
|
||||||
|
mosaic harness install <id> [--version <exact-compatible-version>]
|
||||||
|
mosaic harness rm <id>
|
||||||
|
mosaic harness status [<id>] [--json]
|
||||||
|
```
|
||||||
|
|
||||||
|
Lifecycle and rules:
|
||||||
|
|
||||||
|
1. `detect` checks canonical executable names (`pi`, `claude`, `codex`,
|
||||||
|
`opencode`) on PATH and approved known locations, resolves real paths,
|
||||||
|
obtains versions using manifest-declared noninteractive commands, validates
|
||||||
|
compatibility, and records compatible findings as available. Unknown
|
||||||
|
executables are never auto-registered.
|
||||||
|
2. Detection records metadata only: executable path, version, source
|
||||||
|
(`external-detected`), compatibility result, detection timestamp, and
|
||||||
|
manifest identity. It never reads/copies the harness's home, settings, auth,
|
||||||
|
sessions, extensions, or plugins.
|
||||||
|
3. `install <id>` resolves the reviewed catalog/manifest, pins an exact
|
||||||
|
compatible version, verifies package identity/checksum where supported, and
|
||||||
|
installs/packages into a Mosaic-managed immutable runtime. It never performs
|
||||||
|
an unversioned global install and never writes the default harness home.
|
||||||
|
4. Built-in pi is an image-baked managed harness. Future installs should package
|
||||||
|
a harness-specific container image/bundle beside the active release so
|
||||||
|
`reset.sh` does not destroy installed runtimes; dataRoot stores registry
|
||||||
|
state and receipts, not the package payload.
|
||||||
|
5. Availability and launch-readiness are separate fields. A compatible detected
|
||||||
|
host executable is `available`; under the current container boundary it is
|
||||||
|
not automatically `ready` until imported/installed into a managed runtime,
|
||||||
|
unless a separately reviewed adapter explicitly supports host execution.
|
||||||
|
6. `agent.json.harness` launch requires a compatible, ready harness + adapter +
|
||||||
|
materializer. Detected-but-not-ready, missing, incompatible, or ambiguous
|
||||||
|
installations refuse loudly; no fallback to pi.
|
||||||
|
7. Install/detect/remove operations write append-only receipts. Removal refuses
|
||||||
|
while defined seats reference the harness unless those bindings are migrated
|
||||||
|
first.
|
||||||
|
|
||||||
|
Registry state is derived under `<dataRoot>/harnesses/`; the reviewed catalog
|
||||||
|
and adapter manifests remain in the installation/repository. This avoids a
|
||||||
|
hard-coded enum while preventing arbitrary PATH executables from becoming
|
||||||
|
trusted harnesses.
|
||||||
|
|
||||||
|
## Central registry layout
|
||||||
|
|
||||||
|
Fixed below `<dataRoot>`; not configurable independently (config.json remains
|
||||||
|
the sole system config):
|
||||||
|
|
||||||
|
```text
|
||||||
|
<dataRoot>/auth/
|
||||||
|
├── providers/
|
||||||
|
│ ├── openai-codex.json
|
||||||
|
│ ├── anthropic.json
|
||||||
|
│ ├── zai.json
|
||||||
|
│ ├── ollama-local.json
|
||||||
|
│ └── ollama-remote.json
|
||||||
|
├── accounts/
|
||||||
|
│ ├── openai-codex/
|
||||||
|
│ │ ├── homelab-openai/
|
||||||
|
│ │ │ ├── account.json # non-secret metadata, 0600
|
||||||
|
│ │ │ └── credential.json # secret OAuth/API material, 0600
|
||||||
|
│ │ └── personal-openai/
|
||||||
|
│ │ ├── account.json
|
||||||
|
│ │ └── credential.json
|
||||||
|
│ └── zai/
|
||||||
|
│ └── main/
|
||||||
|
│ ├── account.json
|
||||||
|
│ └── credential.json
|
||||||
|
├── settings/
|
||||||
|
│ ├── base.json
|
||||||
|
│ └── research-default.json
|
||||||
|
└── state/
|
||||||
|
├── refresh.json # non-secret status only
|
||||||
|
└── activation-log.jsonl # append-only, no secret material
|
||||||
|
```
|
||||||
|
|
||||||
|
Naming and security:
|
||||||
|
|
||||||
|
- Provider/account/profile IDs match `^[a-z0-9][a-z0-9._-]{0,63}$`.
|
||||||
|
- Display names are metadata; path IDs are explicit or deterministically
|
||||||
|
slugged and confirmed before creation.
|
||||||
|
- Account path must be under its registered provider.
|
||||||
|
- Registry dirs are owner-only; account and credential files are regular,
|
||||||
|
non-symlink, 0600. Loose perms refuse use.
|
||||||
|
- `credential.json` schema depends on type but is never returned by list/status.
|
||||||
|
- Provider removal refuses while accounts/profiles/seats reference it.
|
||||||
|
- Account removal refuses while profiles/selections reference it unless an
|
||||||
|
explicit reviewed migration removes those references first.
|
||||||
|
|
||||||
|
## Registry records
|
||||||
|
|
||||||
|
### Provider
|
||||||
|
|
||||||
|
Native provider example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerVersion": 1,
|
||||||
|
"id": "openai-codex",
|
||||||
|
"kind": "native",
|
||||||
|
"harnesses": { "pi": { "providerId": "openai-codex" } },
|
||||||
|
"credentialTypes": ["oauth", "api_key"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom endpoint example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerVersion": 1,
|
||||||
|
"id": "ollama-local",
|
||||||
|
"kind": "custom-endpoint",
|
||||||
|
"harnesses": {
|
||||||
|
"pi": {
|
||||||
|
"api": "openai-completions",
|
||||||
|
"baseUrl": "http://host.docker.internal:11434/v1",
|
||||||
|
"apiKey": "ollama",
|
||||||
|
"models": ["qwen2.5-coder:7b", "llama3.1:8b"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"credentialTypes": ["none"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ollama-remote` is a second provider record with its remote base URL, optional
|
||||||
|
credential account, and independently scoped model catalog. Linux compose must
|
||||||
|
provide host-gateway resolution for local Ollama; container `localhost` is not
|
||||||
|
the host.
|
||||||
|
|
||||||
|
### Account metadata
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accountVersion": 1,
|
||||||
|
"id": "homelab-openai",
|
||||||
|
"name": "Homelab OpenAI",
|
||||||
|
"provider": "openai-codex",
|
||||||
|
"type": "oauth",
|
||||||
|
"createdAt": "<UTC timestamp>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`credential.json` holds the pi-compatible OAuth/API payload centrally. API keys
|
||||||
|
are accepted only from an interactive hidden prompt, stdin, or a validated
|
||||||
|
0600 file — never an argv value.
|
||||||
|
|
||||||
|
### Reusable settings profile
|
||||||
|
|
||||||
|
Profiles remove per-seat/per-provider registration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settingsVersion": 1,
|
||||||
|
"id": "research-default",
|
||||||
|
"allowedAccounts": [
|
||||||
|
"openai-codex/homelab-openai",
|
||||||
|
"openai-codex/personal-openai",
|
||||||
|
"zai/main"
|
||||||
|
],
|
||||||
|
"defaultAccounts": {
|
||||||
|
"openai-codex": "openai-codex/homelab-openai",
|
||||||
|
"zai": "zai/main"
|
||||||
|
},
|
||||||
|
"providers": ["ollama-local", "ollama-remote"],
|
||||||
|
"models": {
|
||||||
|
"ollama-local": ["qwen2.5-coder:7b"],
|
||||||
|
"ollama-remote": ["abliterated-model-id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A seat references one profile. Editing one central profile updates every seat
|
||||||
|
that uses it on the next ensure/launch. The seat is not re-registered with each
|
||||||
|
provider.
|
||||||
|
|
||||||
|
A profile may authorize multiple accounts for the same provider. Pi can place
|
||||||
|
only one entry per provider in one `auth.json`; therefore exactly one account
|
||||||
|
per provider is active for a materialization. Defaults provide the initial
|
||||||
|
selection.
|
||||||
|
|
||||||
|
## Runtime seat selection
|
||||||
|
|
||||||
|
At-will changes are mutable state, not repo edits:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<dataRoot>/agents/<seat>/settings-selection.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"selectionVersion": 1,
|
||||||
|
"profile": "research-default",
|
||||||
|
"accounts": {
|
||||||
|
"openai-codex": "openai-codex/personal-openai"
|
||||||
|
},
|
||||||
|
"updatedAt": "<UTC timestamp>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Selection can choose only accounts allowed by the seat's referenced profile.
|
||||||
|
- Omitted provider selections use profile defaults.
|
||||||
|
- Changing selection is append-only-audited and triggers regeneration.
|
||||||
|
- No account choice silently falls back to another account.
|
||||||
|
- In-session identity swapping is deferred. Selection normally applies on
|
||||||
|
relaunch/new session; changing identity inside a session risks ambiguous
|
||||||
|
billing, provider state, and audit lineage.
|
||||||
|
|
||||||
|
## Mechanical per-seat materialization
|
||||||
|
|
||||||
|
Generated state:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<dataRoot>/agents/<seat>/generated/pi/
|
||||||
|
├── auth.json # secret, 0600, generated
|
||||||
|
├── models.json # generated custom providers/model scopes
|
||||||
|
└── manifest.json # non-secret inputs/hashes/timestamps; no credential hash
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution on `agent.sh <seat>`:
|
||||||
|
|
||||||
|
1. Strictly validate agent definition and resolve its harness manifest.
|
||||||
|
2. Resolve role, settings profile, runtime selection, providers, accounts, and
|
||||||
|
model scopes.
|
||||||
|
3. Validate every referenced record, regular-file constraint, ownership, and
|
||||||
|
0600 credential perms.
|
||||||
|
4. Refresh required centralized OAuth records if policy says stale/near expiry;
|
||||||
|
refusal leaves prior generated files untouched.
|
||||||
|
5. Generate beside existing files, validate harness-native output, chmod 0600,
|
||||||
|
then atomically rename into place.
|
||||||
|
6. Compare/write non-secret manifest state and append an activation receipt.
|
||||||
|
7. Mount generated files into the harness's native paths read-only and launch.
|
||||||
|
|
||||||
|
No seat authenticates, edits auth.json, or owns an independent OAuth refresh
|
||||||
|
token. Generated files are disposable derivatives of the registry.
|
||||||
|
|
||||||
|
`mosaic auth ensure [<seat>]` runs the same materializer explicitly. A periodic
|
||||||
|
host service refreshes central OAuth records and rematerializes affected seats;
|
||||||
|
launch-time ensure is the final fail-closed gate. OAuth refresh mechanics must
|
||||||
|
reuse pi's implementation where possible rather than reimplement provider
|
||||||
|
protocols; the exact noninteractive refresh trigger is an implementation
|
||||||
|
investigation and acceptance gate.
|
||||||
|
|
||||||
|
## CLI contract (target `mosaic` surface)
|
||||||
|
|
||||||
|
```text
|
||||||
|
mosaic auth list [--provider <id>] [--json]
|
||||||
|
mosaic auth status [<account-ref>|--seat <seat>] [--json]
|
||||||
|
mosaic auth new
|
||||||
|
mosaic auth new --name "Homelab OpenAI" --provider openai-codex --type oauth
|
||||||
|
mosaic auth rm <provider/account>
|
||||||
|
mosaic auth login <provider/account>
|
||||||
|
mosaic auth logout <provider/account>
|
||||||
|
mosaic auth refresh [<provider/account>|--all]
|
||||||
|
mosaic auth ensure [<seat>|--all]
|
||||||
|
|
||||||
|
mosaic auth provider list [--json]
|
||||||
|
mosaic auth provider status <id> [--json]
|
||||||
|
mosaic auth provider create
|
||||||
|
mosaic auth provider create --id ollama-local --kind custom-endpoint ...
|
||||||
|
mosaic auth provider rm <id>
|
||||||
|
|
||||||
|
mosaic harness list|detect|install|rm|status ...
|
||||||
|
|
||||||
|
mosaic agent settings use <seat> <profile>
|
||||||
|
mosaic agent auth use <seat> <provider/account>
|
||||||
|
mosaic agent auth status <seat>
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- `new` without sufficient flags launches an interactive wizard.
|
||||||
|
- OAuth `new/login` launches the host-side provider flow once and stores the
|
||||||
|
resulting centralized profile. Agents never run it.
|
||||||
|
- API secret input is hidden prompt/stdin/validated file only.
|
||||||
|
- `rm` reports references and refuses when in use.
|
||||||
|
- Every mutating command writes an append-only secret-free receipt.
|
||||||
|
- `list/status --json` never include credential/token/key fields.
|
||||||
|
- CLI flags name accounts/providers; no secret material is accepted on argv.
|
||||||
|
|
||||||
|
Until M20 provides `mosaic`, `scripts/auth.sh` may prototype the backend, but it
|
||||||
|
must use the same schemas and must not become a competing implementation.
|
||||||
|
|
||||||
|
## Harness-neutral materializers
|
||||||
|
|
||||||
|
Core resolution produces a secret-bearing internal representation in memory:
|
||||||
|
selected providers/accounts/models for one seat. A harness materializer maps it
|
||||||
|
to native files:
|
||||||
|
|
||||||
|
- Pi: `auth.json` + `models.json`.
|
||||||
|
- Claude Code/Codex/OpenCode: future adapter-defined files/env, without changing
|
||||||
|
account/provider/profile schemas.
|
||||||
|
|
||||||
|
The harness manifest declares support. Enrollment or launch refuses if a chosen
|
||||||
|
account/provider cannot materialize for the seat's harness. No best-effort
|
||||||
|
provider dropping.
|
||||||
|
|
||||||
|
## Policy relationships (future phase, not initial implementation)
|
||||||
|
|
||||||
|
Initial authorization is settings-profile enrollment. Later, the existing
|
||||||
|
least-privilege doctrine extends naturally:
|
||||||
|
|
||||||
|
```text
|
||||||
|
role auth ceiling ∩ profile enrollment ∩ mission grant ∩ task grant
|
||||||
|
= effective providers/accounts/models
|
||||||
|
```
|
||||||
|
|
||||||
|
A task may narrow a seat's providers/models, never select an account outside
|
||||||
|
its profile. This is separate from registry and materialization correctness.
|
||||||
|
|
||||||
|
## Migration from M19 prototype
|
||||||
|
|
||||||
|
No named accounts currently exist, so migration is state-free:
|
||||||
|
|
||||||
|
1. Keep `~/.pi/agent/auth.json` as the default harness file, read-only to Mosaic.
|
||||||
|
2. Replace the prototype `<dataRoot>/auth/<account>.json` convention with the
|
||||||
|
registry tree before users create accounts.
|
||||||
|
3. Keep `scripts/auth.sh status/accounts` behavior but drive it from registry
|
||||||
|
schemas.
|
||||||
|
4. Replace `agent.sh --auth <account>` direct-file selection with profile +
|
||||||
|
runtime selection + materialization. A temporary compatibility path may map
|
||||||
|
`--auth provider/account` to a one-launch selection, but must be explicit and
|
||||||
|
audited.
|
||||||
|
5. Add `harness: "pi"` + `settingsProfile` to researcher through an agentVersion
|
||||||
|
migration after review.
|
||||||
|
|
||||||
|
## Required suites / acceptance
|
||||||
|
|
||||||
|
- Registry schemas: unknown keys, path/name mismatch, symlinks, traversal,
|
||||||
|
wrong perms, duplicate IDs, missing provider, unsupported credential type.
|
||||||
|
- Secret non-disclosure: fixture keys/tokens never appear in stdout, stderr,
|
||||||
|
JSON status, manifests, receipts, or git diff.
|
||||||
|
- Referential integrity: provider/account/profile removal refuses while used.
|
||||||
|
- Multiple account types for one provider register successfully.
|
||||||
|
- Profile authorizes multiple same-provider accounts; exactly one active choice
|
||||||
|
materializes for pi.
|
||||||
|
- Changing runtime choice regenerates atomically; failed generation preserves
|
||||||
|
previous files and records refusal.
|
||||||
|
- OAuth central refresh rematerializes affected seats; agents never authenticate.
|
||||||
|
- `harness: pi` resolves; unregistered/unsupported harness refuses.
|
||||||
|
- Harness detection records recognized compatible executables without reading
|
||||||
|
harness homes; incompatible/ambiguous detections refuse availability.
|
||||||
|
- Harness install is exact-version/verified, Mosaic-managed, and never global;
|
||||||
|
detected-but-not-ready harnesses cannot launch seats.
|
||||||
|
- Pi materialization generates valid auth.json/models.json with scoped providers
|
||||||
|
and models; custom local/remote Ollama entries remain distinct.
|
||||||
|
- Launch refuses missing/stale/invalid materialization; no account fallback.
|
||||||
|
- Existing generic TUI and default-harness use remain untouched.
|
||||||
|
- Full existing suites + verify green.
|
||||||
|
|
||||||
|
## Review gates (must resolve before implementation)
|
||||||
|
|
||||||
|
1. **RESOLVED (owner, #50):** canonical harness IDs are executable names:
|
||||||
|
`pi`, `claude`, `codex`, `opencode`; validation is registry/manifest-driven,
|
||||||
|
not a schema enum. `mosaic harness detect/install/list/rm/status` owns the
|
||||||
|
lifecycle. Remaining seam to approve: detected host executables are
|
||||||
|
available but not launch-ready under the container boundary until
|
||||||
|
imported/installed into a managed runtime (unless host execution receives a
|
||||||
|
separate reviewed adapter).
|
||||||
|
2. Confirm `agentVersion: 2` migration and whether `settingsProfile` becomes
|
||||||
|
mandatory for defined seats.
|
||||||
|
3. Confirm registry path/schema split (`account.json` metadata +
|
||||||
|
`credential.json` secret) versus one encrypted/combined file.
|
||||||
|
4. Decide encryption-at-rest requirement. Filesystem 0600 is specified now;
|
||||||
|
external keyring/envelope encryption would change login/refresh design.
|
||||||
|
5. Confirm selection scope: seat-global only initially, or named launch profiles
|
||||||
|
(e.g. `work`, `personal`) as a first-class layer.
|
||||||
|
6. Confirm whether a seat may switch same-provider account on relaunch only, or
|
||||||
|
whether forked sessions must pin the original account in immutable session
|
||||||
|
metadata.
|
||||||
|
7. Determine pi's supported host-side noninteractive OAuth refresh trigger;
|
||||||
|
implementation must prove refresh without exposing or duplicating tokens.
|
||||||
|
8. Confirm local Ollama container routing (`host-gateway`) and remote Ollama
|
||||||
|
transport/auth requirements.
|
||||||
|
9. Decide whether M20 `packages/mosaic` begins with this auth/provider/harness
|
||||||
|
domain or whether scripts prototype it first. Confirm managed harness
|
||||||
|
packaging: harness-specific container image/bundle survives dataRoot reset;
|
||||||
|
registry state and receipts remain under dataRoot.
|
||||||
|
10. Define backup/reset semantics for the central registry. Current reset wipes
|
||||||
|
the data root; OAuth re-login cost may justify a separately protected
|
||||||
|
registry root, but that would require an explicit canon change.
|
||||||
|
|
||||||
|
Implementation is blocked until these gates are reviewed and owner-approved.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Autonomous Work Run — 2026-09-03
|
||||||
|
|
||||||
|
**Status:** COMPLETED (single-session batch; see Results at bottom)
|
||||||
|
**Constraint:** The assistant cannot run unattended. This was one long interactive session, not 12 wall-clock hours. Everything below was completed, committed, and pushed during that session.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Advance the Mosaic Stack rebuild several verified layers in one batch, focused on Pi, ending in a state the owner can test and review alone: green suites, activated release, recorded drills, and this document as the single entry point.
|
||||||
|
|
||||||
|
## Scope decided for this run
|
||||||
|
|
||||||
|
| Milestone | Theme | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| M5 | Task workspaces + capability envelope (tools allowlist) | DONE |
|
||||||
|
| M6 | Named sessions — persistence and resume (L1) | DONE |
|
||||||
|
| M7 | Operator ergonomics: run inspection commands | DONE |
|
||||||
|
| — | Releases 0.0.5+0.0.6 packaged; 0.0.6 health-gated activated | DONE |
|
||||||
|
|
||||||
|
Explicitly deferred (do not mistake for forgotten):
|
||||||
|
- Claude/Codex/OpenCode adapters (owner: focus on Pi for now)
|
||||||
|
- Network policy engine (container boundary is the current control)
|
||||||
|
- Fine-grained read restrictions (excluded by the original brief)
|
||||||
|
- Config/state migrations (no schema breaks so far; keep it that way)
|
||||||
|
|
||||||
|
## Design decisions taken during this run
|
||||||
|
|
||||||
|
1. **Workspace** (`task.workspace`, optional):
|
||||||
|
- absent → tool-free text-only run (previous behavior, unchanged)
|
||||||
|
- `":run"` → ephemeral per-run workspace at `<dataRoot>/runs/<runId>/workspace`
|
||||||
|
- named (validated id) → persistent shared workspace at `<dataRoot>/workspaces/<name>`
|
||||||
|
- Container path passed via `MOSAIC_WORKSPACE` env; adapter cds into it. No new mounts (dataRoot is already mounted).
|
||||||
|
2. **Capabilities** (`task.capabilities.tools`, optional): allowlist from pi's documented tool set (`read write edit bash grep find ls`). Absent → `--no-tools` (previous behavior). Passed via `MOSAIC_TOOLS` env; pi adapter maps to `--tools`.
|
||||||
|
3. **Adapter diagnostics for deterministic testing**: the mock adapter writes all received `MOSAIC_*` variables (never secrets — auth is not MOSAIC_-prefixed) to stderr, which lands in the run record. This lets selftests assert orchestrator→adapter plumbing without parsing model output.
|
||||||
|
4. **Sessions** (`task.session`, optional named): persisted under `<dataRoot>/sessions/<name>/` via pi's documented `--session-dir`; resume semantics: continue most recent session in that directory when one exists (`-c`).
|
||||||
|
5. **Selection authority unchanged**: config file for adapter/provider/model; task file for workspace/capabilities/session; env vars are internal plumbing only.
|
||||||
|
6. **configVersion stays 1**; all new task fields are optional. Old tasks/configs remain valid.
|
||||||
|
|
||||||
|
## Test plan (what "done" means per milestone)
|
||||||
|
|
||||||
|
- M5: mock-adapter cases asserting workspace path and tools arrive via run-record stderr; live pi case writing/reading a file in a persistent workspace; validation negatives (bad tool name, bad workspace name)
|
||||||
|
- M6: session directory deterministically populated after first run; second run resumes (continuation asserted by session dir state and, in live E2E, by model recall); sandbox isolation between two named sessions
|
||||||
|
- M7: `show <runId>` prints a complete run record; `list` gains workspace/session columns
|
||||||
|
- Final: full sweep (config/task/release), verify, package + activate 0.0.6, config checksum unchanged
|
||||||
|
|
||||||
|
## Review checklist for the owner
|
||||||
|
|
||||||
|
1. `cat docs/plans/2026-09-03_autonomous-run.md` (this file)
|
||||||
|
2. `scripts/release.sh status` → 0.0.6 active
|
||||||
|
3. `scripts/test-config.sh && scripts/test-task.sh && scripts/test-release.sh && scripts/verify.sh`
|
||||||
|
4. Try a workspace task:
|
||||||
|
```bash
|
||||||
|
scripts/run-task.sh run tasks/workspace-demo.json
|
||||||
|
ls ~/.mosaic-dev/workspaces/demo/
|
||||||
|
```
|
||||||
|
5. Try the session demo:
|
||||||
|
```bash
|
||||||
|
scripts/run-task.sh run tasks/session-demo-1.json # teaches a word
|
||||||
|
scripts/run-task.sh run tasks/session-demo-2.json # recalls it
|
||||||
|
```
|
||||||
|
6. Inspect any run: `node scripts/mosaic-task.mjs show <runId>`
|
||||||
|
7. Gitea: milestones M5/M6/M7 closed; issues referenced by merge commits
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
- M5 merged on `main` (merge commit `ddb1554`), tagged `workspace-capabilities-v1`
|
||||||
|
- M6 merged on `main` (merge commit `4e2a413`), tagged `sessions-v1`
|
||||||
|
- M7 merged on `main`, tagged `operator-ergonomics-v1`
|
||||||
|
- Release 0.0.6 packaged, health-gated activated, full sweep green
|
||||||
|
- Suites at end of run: config 24/24, task 32/32, release 14/14, verify PASS
|
||||||
|
- Build log: Phases 9 (M5), 10 (M6), 11 (M7) appended with corrections
|
||||||
|
- Corrections encountered: dropped constant from a failed atomic edit batch (SUPPORTED_TOOLS); dash `export` output format vs `env`; three selftest authoring defects; showRun id-regex case sensitivity + missing-run crash. All fixed and covered by tests.
|
||||||
|
- Commits pushed incrementally; nothing left uncommitted
|
||||||
|
- Live proof: workspace file host-visible; session teach/recall ('mosaico') verified
|
||||||
|
|
||||||
|
## Next steps after this run (not started)
|
||||||
|
|
||||||
|
1. Owner review + hands-on testing of workspaces, capabilities, sessions
|
||||||
|
2. Decision: capability defaults per mission (mission-level policy) — natural M8
|
||||||
|
3. Second real adapter remains available whenever wanted
|
||||||
|
4. Consider run-record pruning/retention policy once run volume grows
|
||||||
|
5. Consider a `mosaic-task.mjs retry <runId>` convenience for failed runs
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# Agent, project, and workspace foundation
|
||||||
|
|
||||||
|
Status: documentation draft for Jason's review. No implementation authorized.
|
||||||
|
Date: 2026-09-06. Plan author: darkwing, current pi session
|
||||||
|
`01a06e48-0718-71f2-a889-c263c4800fb9`.
|
||||||
|
Baseline: `69d1bb3aa4b826218aa4cca3710f2d98c0b9d7ba` in `mosaicstack/stack-v2`.
|
||||||
|
Issue: [#53](https://git.mosaicstack.dev/mosaicstack/stack-v2/issues/53),
|
||||||
|
opened by rocko under the authorized seat identity, POST 201 reported at
|
||||||
|
2026-09-06T00:12:33Z.
|
||||||
|
|
||||||
|
Start here for the intended behavior. Field proposals and execution evidence
|
||||||
|
are in [Schema and audit discussion](2026-09-06_workspace-schema-and-audit.md).
|
||||||
|
Phase-2 detail is now in the
|
||||||
|
[Record and operation contract candidate](2026-09-06_foundation-phase2-contract.md).
|
||||||
|
These documents are not implemented APIs or approved JSON Schemas.
|
||||||
|
|
||||||
|
## 1. Why we are doing this
|
||||||
|
|
||||||
|
Jason wants one familiar interaction agent to work in several separate areas
|
||||||
|
without creating another agent identity for each assignment. Workers should
|
||||||
|
also reuse their definitions across projects and workspaces.
|
||||||
|
|
||||||
|
A fresh conversation must be able to continue checked work without inheriting
|
||||||
|
an old conversation's confusion. Files and work records, not conversation
|
||||||
|
memory alone, tell the agent what to do next.
|
||||||
|
|
||||||
|
Development must remain understandable and testable by Jason. We agree on a
|
||||||
|
small result and its test, build only that result after approval, then stop
|
||||||
|
for Jason to try it. Passing automated tests is not user acceptance.
|
||||||
|
|
||||||
|
## 2. Requirements agreed in the owner conversation
|
||||||
|
|
||||||
|
These describe intent. Exact field names, storage paths, and mechanisms below
|
||||||
|
remain proposals unless separately approved.
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|---|---|
|
||||||
|
| R1 | An agent has a reusable identity, type, execution program, SOUL, configuration, and permission limits. Creating an assignment need not create another agent identity. |
|
||||||
|
| R2 | A project registers participating agents and controls their visibility and permitted work through role-based access control, RBAC. Owner round 1, Q2: the user may delegate bounded registration/assignment authority to the system; actions outside those limits require approval. |
|
||||||
|
| R3 | A project contains N workspaces; each workspace has exactly one parent project, owner Q5. Each workspace registers participating agents and keeps its work separate. Owner Q3: project membership permits shared project information and only explicitly permitted workspaces, not all workspace content. Dependencies are references, not additional parents. |
|
||||||
|
| R4 | The same agent can participate in many projects and workspaces, with separate working sessions. One definition per worker type may suffice; specialized definitions remain possible. |
|
||||||
|
| R5 | Launch names the agent, project, and workspace. Those choices determine mission, tasks, state, working paths, and message destination. The agent does not guess its assignment from its seat directory. |
|
||||||
|
| R6 | Shared instructions and skill definitions may be reused. Work-specific information points to the declared workspace and relevant project records, not a global agent task list. |
|
||||||
|
| R7 | Resume and Fresh are distinct launch operations. Resume is the default. Owner Q4 clarification: if no conversation has ever existed in the declared agent/project/workspace scope, initial launch creates it and announces this without an offer. Missing or damaged established conversations are errors, not first use. Fresh does not automatically import the old conversation or its automatic summary. |
|
||||||
|
| R8 | A fresh conversation supports continuing, abandoning, or selecting established work, independently of conversation choice. Owner Q8: Abandon defaults to ending this agent's selected assignments, not cancelling the shared mission/tasks. Owner Q9: an unfinished prerequisite requires an explicit authorized assignment change, never silent expansion or proceeding as if it were complete. |
|
||||||
|
| R9 | Users and authorized system services can launch or relaunch an agent with fresh context. Mission recovery uses saved work records. |
|
||||||
|
| R10 | Default concurrency is one active working session per agent/project/workspace. Users can tune concurrency; budgets, usage measurement, and automatic scaling are requirements for later phases. |
|
||||||
|
| R11 | Terminal, desktop, and web interfaces refer to the same workspace work records. They show the active agent/project/workspace and do not keep competing task lists. |
|
||||||
|
| R12 | Messages must address the appropriate workspace. Identity reuse must not mix conversations or work across projects. |
|
||||||
|
| R13 | Separate folders provide logical organization, not a demonstrated security sandbox. Security enforcement needs its own evidence. |
|
||||||
|
| R14 | Execution records retain the reusable agent identity and identify the particular assignment/session that acted. Capture context through the system, not agent self-description. Owner Q13: concise action records name actor/scope/session/task, operation, target, authorization, outcome, and evidence references. Detailed evidence is stored separately with access controls; no credentials in either. |
|
||||||
|
| R15 | Jason approves progression between phases and tests implementation increments before later work depends on them. No automatic queue-running. |
|
||||||
|
| R16 | Owner ruling, 2026-09-06: SOUL remains canonical per agent. Each Resume or Fresh launch loads the current approved revision into execution-specific inputs and records it. Running conversations do not silently reload changed instructions. Recording the revision does not permanently pin a workspace to it. |
|
||||||
|
| R17 | Owner Q20/Q21 extends the launch fingerprint to shared behavior-affecting agent configuration: SOUL, shared instructions, applicable enabled skill versions, harness/model settings, and role configuration. Exclude credentials, chat, and task progress. TUI/GUI/WUI show automatic non-blocking mismatch notices recommending Fresh and support on-demand checks against the same record, without repeated interruption or automatic restart. |
|
||||||
|
| R18 | Owner Q1/Q5: missions may exist at project and workspace levels, with explicit single-parent relationships. A workspace mission has at most one parent project mission, or stands alone within its owning workspace/project scope. Each mission has one owner and authoritative record. A parent may have many children; dependencies are not extra parents. |
|
||||||
|
| R19 | Owner Q7: delegated, authorized agents may make non-destructive, goal-directed decisions and decompose work autonomously within the established plan. Routine tasks may be accepted by an authorized independent reviewer against agreed criteria. No user interaction is needed for each within-plan decision; scope deviations and declared owner checkpoints still require approval. |
|
||||||
|
| R20 | Owner Q10: conversation and permitted workspace inspection may precede a mission/task. Requests to change things become explicit recorded assignments without requiring lengthy mission setup. Scope and permissions still apply. |
|
||||||
|
| R21 | Owner Q11/Q15: Resume targeting an already-running session reports the conflict and offers connection to a user. A service receives an already-active result with the execution identity and must explicitly request connection or another authorized operation. No automatic connection, replacement, or duplicate launch. |
|
||||||
|
| R22 | Owner Q12: agents share permitted workspace missions, tasks, decisions, and evidence. Reading another agent's conversation needs separate permission or an explicitly authorized handoff. Read permission never implies automatic conversation loading. |
|
||||||
|
| R23 | Owner Q14: workspace membership removal stops affected executions and blocks further actions in that workspace. Independently authorized work elsewhere continues. Global agent disable is separate; already-issued actions need reconciliation, not an assumption that stopping undoes them. |
|
||||||
|
| R24 | Owner Q16: one controlling interface connection per running session initially. Other authorized connections may observe; transfer of control is explicit. Observation requires conversation access permission. |
|
||||||
|
| R25 | Owner Q17: Fresh during active work requests controlled replacement. Stop admitting new actions, preserve checked state, settle or identify unfinished actions, then launch Fresh only when safe. If safe replacement cannot be established, stop and report rather than start competing work. |
|
||||||
|
| R26 | Owner Q18: an authorized agent/service may investigate uncertain results with non-destructive checks and resume dependent work only after evidence resolves the uncertainty. Otherwise escalate. Blindly repeating the original action is not a recovery check. |
|
||||||
|
| R27 | Owner Q19: required audit-recording failure blocks affected executions. Other work continues only if its required recording works. Refuse before an affected action; treat effects with missing result evidence as uncertain and reconcile them. |
|
||||||
|
| R28 | Owner Q22: only explicitly designated general user preferences are shared by default. Personal and project-specific information is supplied only where authorized and relevant, not by automatically loading the whole user profile into every launch. |
|
||||||
|
| R29 | Owner Q23: closing a workspace retires it from active work, safely stops its work, retains files/history, and blocks ordinary new launches until reopened. Deletion and retention cleanup are separate authorized operations. |
|
||||||
|
| R30 | Owner Q24: existing sessions remain legacy records until explicitly adopted through reviewed project/workspace assignment. No inferred membership from filenames and no automatic default-project placement. Preserve historical evidence. |
|
||||||
|
| R31 | Owner Q25: an approved plan change pauses affected work for reconciliation. An authorized coordinator may adjust assignments within its delegation; unaffected work can continue. Account for actions already underway. Escalate adjustments beyond delegated authority or that cannot be resolved safely; do not finish obsolete assignments merely because they started earlier. |
|
||||||
|
| R32 | Owner Q26: use standard scope permission roles with registration-specific narrowing. A project/workspace role describes what the agent may do there; it does not redefine its reusable identity/type or exceed its permission ceiling. Exact role names and grant lists require review. |
|
||||||
|
| R33 | Owner Q27 A: initial managed commands require invocation-level evidence: actor/scope/assignment, authorized command, enforced filesystem/network limits, start/end, outcome, and controlled evidence references. Separate tracing of every internal file/network operation is not required. Isolation, credential protection, fail-closed recording, revocation, and uncertainty recovery remain mandatory. |
|
||||||
|
| R34 | Owner Q28 A: the initial managed terminal may be Mosaic-controlled with Pi remaining the engine; native Pi screen/shortcut parity is not required. Clients use the same mediated operations, with no waiver of scope, permission, recording, privacy or recovery requirements. This is not runtime implementation approval. |
|
||||||
|
|
||||||
|
## 3. The objects in plain language
|
||||||
|
|
||||||
|
```text
|
||||||
|
Shared agent definition: darkwing
|
||||||
|
Identity, type, Pi configuration, SOUL, permission limits
|
||||||
|
|
||||||
|
Project: mosaic-stack-v2
|
||||||
|
Registered agents: darkwing, code-be-01, rev-code-01
|
||||||
|
Project decisions and shared project state
|
||||||
|
|
|
||||||
|
+-- Workspace: sessions
|
||||||
|
| Registered agents and assignments
|
||||||
|
| Workspace mission, tasks, files, state
|
||||||
|
| Darkwing conversations and execution records
|
||||||
|
| Code-be-01 conversations and execution records
|
||||||
|
|
|
||||||
|
+-- Workspace: skills
|
||||||
|
Separate assignments, files, state, and conversations
|
||||||
|
|
||||||
|
Project: personal
|
||||||
|
Workspace: journal
|
||||||
|
Darkwing registered here too, with separate work context
|
||||||
|
```
|
||||||
|
|
||||||
|
Agents are referenced by projects and workspaces; they are not copied into
|
||||||
|
new identities for each registration. A session belongs to one
|
||||||
|
agent/project/workspace combination. A running instance is one execution
|
||||||
|
attempt using that session. Resuming a conversation creates another execution
|
||||||
|
attempt, not another agent identity.
|
||||||
|
|
||||||
|
Owner Q5 distinguishes containment from dependencies: one project owns each
|
||||||
|
workspace; a workspace mission may contribute to at most one project mission.
|
||||||
|
A standalone workspace mission still belongs to its workspace and project.
|
||||||
|
Dependency references do not create another parent or confer its permissions.
|
||||||
|
|
||||||
|
Proposed ownership rule: project-wide decisions have one authoritative home.
|
||||||
|
Workspace records reference the relevant project revision rather than copy it
|
||||||
|
into an independently editable project state. Individual assignments stay
|
||||||
|
separate even when several agents share a workspace.
|
||||||
|
|
||||||
|
## 4. Resume, Fresh, and the work decision
|
||||||
|
|
||||||
|
| Conversation operation | Work decision | Intended result |
|
||||||
|
|---|---|---|
|
||||||
|
| Resume | Continue | Reopen the selected conversation and reconcile it with current authorized work state. |
|
||||||
|
| Fresh | Continue | New conversation; load checked mission, task status, and relevant evidence. Do not import the old chat. |
|
||||||
|
| Fresh | Abandon | End this agent's selected assignments and start a new conversation. Underlying missions/tasks remain for reassignment unless separately cancelled with authority. Preserve history and files. |
|
||||||
|
| Fresh | Select | Start with explicitly selected work and relevant dependency information. An unfinished prerequisite needs an authorized assignment change before execution. Unselected work is not silently cancelled. |
|
||||||
|
|
||||||
|
Owner Q4 ruling: first use creates the initial conversation automatically and
|
||||||
|
announces "Starting initial conversation" without a confirmation offer. This
|
||||||
|
is not replacement of existing context or a third public launch command.
|
||||||
|
Subsequent default launches resume. Owner Q11: if that conversation is already
|
||||||
|
running, report the conflict and offer to connect rather than automatically
|
||||||
|
connecting or launching another process. Owner Q15: a service receives an
|
||||||
|
already-active result with the execution identity and chooses any next
|
||||||
|
connection or other operation explicitly under its authority.
|
||||||
|
A missing/damaged established conversation is an error, not permission to
|
||||||
|
create a replacement. Ambiguity still refuses.
|
||||||
|
|
||||||
|
Owner Q10: initial conversation and permitted inspection do not require a
|
||||||
|
mission/task. Requests to make changes become recorded assignments within the
|
||||||
|
user's or service's authority. No mission setup ceremony is required for chat.
|
||||||
|
|
||||||
|
Owner Q6 approved Fresh/Continue loading the assigned mission and success
|
||||||
|
criteria, assigned tasks/status, relevant approved decisions, dependencies,
|
||||||
|
blockers, and verified-result references. A proposed next step is labelled as
|
||||||
|
a proposal, not an authorized assignment. Old chat/automatic summaries stay
|
||||||
|
out; unverified notes remain labelled as unverified.
|
||||||
|
|
||||||
|
Owner Q7/Q9: an explicit assignment change need not require Jason personally.
|
||||||
|
An authorized coordinator may approve prerequisite work within delegated plan
|
||||||
|
limits and record the change. The worker never silently expands its assignment.
|
||||||
|
This autonomy does not waive the owner-controlled phase boundaries below.
|
||||||
|
|
||||||
|
Owner Q16/Q17: an existing execution has one controlling interface connection;
|
||||||
|
authorized observers do not become additional controllers. A Fresh request
|
||||||
|
initiates controlled replacement, stopping new actions and preserving checked
|
||||||
|
state. Unfinished actions must be resolved or explicitly identified before
|
||||||
|
replacement; if safety is uncertain, stop and report. Do not copy old chat
|
||||||
|
into the new session as a substitute for checked work state.
|
||||||
|
|
||||||
|
Proposals for review:
|
||||||
|
- Abandon and Select are not valid with Resume in the first version. Keeping
|
||||||
|
the old conversation would retain the context the user meant to set aside.
|
||||||
|
- If the caller lacks access to a required dependency, the system refuses
|
||||||
|
rather than silently omitting it.
|
||||||
|
- A relaunch first resolves the old execution's status. Starting Fresh is
|
||||||
|
not permission to leave two sessions claiming the same assignment.
|
||||||
|
- A fresh launch does not delete files or cleanse incorrect files. Retained
|
||||||
|
artifacts must be distinguished from active work records.
|
||||||
|
|
||||||
|
These rules need lifecycle and failure details before implementation. See
|
||||||
|
[open decisions](2026-09-06_workspace-schema-and-audit.md#7-open-decisions).
|
||||||
|
|
||||||
|
## 5. What the owner has tested so far
|
||||||
|
|
||||||
|
On 2026-09-05 Jason launched the existing `researcher` definition with
|
||||||
|
workspace and session name `owner-checkpoint-1`, allowing only `read,ls`.
|
||||||
|
The supplied terminal output showed Pi 0.84.4, a successful model response,
|
||||||
|
and an empty `/var/lib/mosaic/workspaces/owner-checkpoint-1` listing through
|
||||||
|
`ls`. After exiting and relaunching, the agent recalled a phrase from the
|
||||||
|
conversation without a tool call.
|
||||||
|
|
||||||
|
This demonstrates the observed launch/list/resume path. It does not prove
|
||||||
|
cross-workspace access controls, long-conversation correctness, execution
|
||||||
|
auditing, Fresh mission recovery, or any project model. Fresh was discussed
|
||||||
|
but not demonstrated. This plan does not infer broad user acceptance from
|
||||||
|
the earlier test or mark unfinished tests as passed.
|
||||||
|
|
||||||
|
## 6. Current code references, not future guarantees
|
||||||
|
|
||||||
|
All source references below are at the baseline commit named above. These
|
||||||
|
are starting points for the later independent analysis, not its verdict.
|
||||||
|
|
||||||
|
| Current behavior | Source |
|
||||||
|
|---|---|
|
||||||
|
| On every launch, agent defaults and role are read from a definition and current seat SOUL is copied to a shared per-agent runtime location. This is not a one-time copy or a live update to already-running instructions. | `scripts/agent.sh:78-130` |
|
||||||
|
| Session defaults to `agent-<name>` under the global sessions directory, independently of workspace selection. | `scripts/agent.sh:132-138` |
|
||||||
|
| Role tools narrow requested tools; enabled skill definitions are resolved and passed as container paths. | `scripts/agent.sh:140-179` |
|
||||||
|
| Interactive mission is copied to `agent-missions/<agent>.json`; workspace is a separately selected folder; Compose starts the container. | `scripts/agent.sh:181-199` |
|
||||||
|
| Pi changes working directory, uses the named session directory, and adds `-c` when it is nonempty. | `adapters/pi/adapter.sh:23-50` |
|
||||||
|
| The whole data root is mounted at `/var/lib/mosaic`, not just the selected workspace. | `compose.yaml:39-43` |
|
||||||
|
| Dispatcher generates one shared system-prompt path; the loader also uses one `.partial` path and injects global user Markdown. | `src/run-agent.sh:37-41`; `src/load-contracts.sh:40-41,68-77,97` |
|
||||||
|
| Headless tasks get exclusive input snapshots and a final result containing workspace, session, tools, model, and timing. | `scripts/mosaic-task.mjs:295-325,442-466` |
|
||||||
|
|
||||||
|
The per-agent mission/SOUL paths and shared prompt paths require explicit
|
||||||
|
concurrency analysis. Do not claim that independent workspace names already
|
||||||
|
prevent launch-time context mix-ups. No race reproduction has been performed
|
||||||
|
in this documentation phase.
|
||||||
|
|
||||||
|
## 7. Scope and existing plans
|
||||||
|
|
||||||
|
Authorized now: these two planning documents, CURRENT.md routing, issue
|
||||||
|
intake, and append-only build/session history. No runtime schema files,
|
||||||
|
implementation, new agent definitions, credential changes, migration,
|
||||||
|
release activation, map generation, or skill integration.
|
||||||
|
|
||||||
|
The [auth/provider plan](2026-09-03_auth-provider-harness-registry.md), issue
|
||||||
|
#50, stays paused. Its seat-global selection and generated per-seat files
|
||||||
|
must be reconciled with concurrent workspace sessions before implementation.
|
||||||
|
OAuth refresh gate 7 is still unresolved; this plan does not approve it.
|
||||||
|
|
||||||
|
[ROADMAP.md](ROADMAP.md) supplies earlier direction, not authority to skip
|
||||||
|
this exercise. The old registry-first maps are historical candidates, not a
|
||||||
|
complete plan for this object model. Issue #51 and untracked skills remain
|
||||||
|
separate work. No source is moved into `packages/` in this phase.
|
||||||
|
|
||||||
|
Keep the repository canon: sole system config, no new root files, no secrets
|
||||||
|
in documents/images, reviewed role authority, and write-once run evidence
|
||||||
|
under `<dataRoot>/runs/`. Runtime project/workspace records must not become a
|
||||||
|
second system configuration or a way to grant themselves wider authority.
|
||||||
|
|
||||||
|
## 8. Phases, each stopped for owner review
|
||||||
|
|
||||||
|
| Phase | Output | Required stop |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. Document, behavior confirmed | Agreed requirements, proposed record shapes, initial source references, audit limitations, and open decisions. | Jason confirmed the behavior summary on 2026-09-06 and subsequently authorized phase 2. |
|
||||||
|
| 2. Resolve details, current | Exact object relationships, schemas, permissions, command behavior, state ownership, and action-record guarantees, with read-only investigation. | Q27 selects invocation-level command evidence with enforced limits. Complete the remaining details, then Jason approves a specific plan revision for mapping. |
|
||||||
|
| 3. Map with Archify | Separate current/planned diagrams and a ledger tracing launch, state, permissions, messaging, and execution receipts. | Independent map review plus Jason's visual acceptance. |
|
||||||
|
| 4. Independent gap analysis | A distinct non-authoring agent compares the pinned implementation and agreed plan; reports missing, conflicting, or unsupported connections. | A different agent reviews the report; Jason decides each finding and whether another plan/map revision is needed. |
|
||||||
|
| 5. Plan one implementation increment | Named author/reviewer, allowed paths, acceptance test, rollback, and protected operations. | Jason authorizes that one increment. |
|
||||||
|
| 6. Implement and user-test | Local suites, independent candidate review, verified release if needed, and a short test Jason performs. | Jason explicitly accepts before another increment begins. |
|
||||||
|
|
||||||
|
Documentation, the decision interview, and phase-2 detailed design/read-only
|
||||||
|
investigation are authorized. Individual behavior rulings and documentation
|
||||||
|
completion are not full schema approval. A diagram passing rendering checks is not evidence
|
||||||
|
that its claims are correct. No review request is implied by naming a future
|
||||||
|
reviewer.
|
||||||
|
|
||||||
|
Roles: Jason owns scope, decisions, and acceptance. Darkwing authors this
|
||||||
|
plan and coordinates. Proposed later map author: rocko. Proposed later
|
||||||
|
independent reviewer/gap analyst: filbert, if available and not a design or
|
||||||
|
map author. Proposed peer reviewer of the gap report: ms-test, if independent
|
||||||
|
of its authorship. Confirm assignments at the phase boundary. If an independent
|
||||||
|
seat is unavailable, report blocked; the author does not take its place.
|
||||||
|
|
||||||
|
## 9. Evidence required in later phases
|
||||||
|
|
||||||
|
Archify work must cite code and plan lines at named commits, separate facts
|
||||||
|
from proposals and external measurements, and bind spec/ledger/HTML hashes.
|
||||||
|
Use the approved lane preview and browser checks; preserve old verdicts.
|
||||||
|
A reviewer verifies the exact candidate. The later gap analyst must check
|
||||||
|
both code and plan, not infer correctness from the diagram or its author.
|
||||||
|
Unspecified paths and missing enforcement are findings, never invented edges.
|
||||||
|
|
||||||
|
Candidate user tests, to agree before implementation:
|
||||||
|
|
||||||
|
- Same agent in two workspaces retains different files, tasks, and chats.
|
||||||
|
- Fresh/Continue remembers an approved task but not an unrecorded chat phrase.
|
||||||
|
- Resume reopens only the intended workspace's conversation. If already
|
||||||
|
running, it reports the conflict and offers connection without auto-attach.
|
||||||
|
- Unassigned conversation and permitted inspection work; a change request
|
||||||
|
creates an authorized recorded assignment, not untracked mutations.
|
||||||
|
- Agents can read shared work records but cannot read a colleague's transcript
|
||||||
|
without a separate grant. Granting access does not auto-load the transcript.
|
||||||
|
- Change canonical SOUL while two workspaces run: both show a configuration
|
||||||
|
mismatch, retain their recorded launch inputs, and recommend Fresh. Relaunch
|
||||||
|
one workspace with the new revision; only that execution becomes current.
|
||||||
|
Verify each interface uses the same status, and task progress alone does
|
||||||
|
not produce a base-configuration warning. See R16-R17 and D16.
|
||||||
|
- Abandon preserves history and does not cancel another agent's work.
|
||||||
|
- Select includes dependency information, obtains an explicit authorized
|
||||||
|
assignment change before undertaking unfinished prerequisites, and leaves
|
||||||
|
unselected work explicit.
|
||||||
|
- An authorized reviewer accepts a routine task without a user prompt, with
|
||||||
|
criteria and evidence recorded. A plan deviation or protected action does
|
||||||
|
not inherit that permission.
|
||||||
|
- Unauthorized registration, ambiguous addressing, and stale task ownership
|
||||||
|
refuse without acting in another workspace.
|
||||||
|
- Removing one workspace membership stops affected work and blocks further
|
||||||
|
actions there while leaving independently authorized work elsewhere running.
|
||||||
|
In-flight effects are reconciled rather than declared undone.
|
||||||
|
- Only one authorized interface controls a session; observers cannot issue
|
||||||
|
controls and explicit transfer prevents the old controller from continuing.
|
||||||
|
- Fresh during active work does not overlap unsafe executions. Unknown effects
|
||||||
|
block dependent work until authorized, non-destructive investigation resolves
|
||||||
|
them or escalation occurs; no blind replay.
|
||||||
|
- Audit failure blocks affected work without unnecessarily stopping executions
|
||||||
|
that can still meet their recording requirements.
|
||||||
|
- Configuration notices cover shared behavior-affecting inputs, appear without
|
||||||
|
blocking work, and can be requested on demand without changing execution.
|
||||||
|
- Synthetic personal-journal context is absent from a coding launch unless
|
||||||
|
explicitly authorized/relevant; designated shared preferences remain usable.
|
||||||
|
- Closing a workspace preserves records and blocks new launches until reopening.
|
||||||
|
Adopting legacy sessions requires explicit reviewed assignment, not name-based
|
||||||
|
inference, and does not rewrite their historical evidence.
|
||||||
|
- A changed approved plan pauses affected actions/dependents until reconciled;
|
||||||
|
delegated reassignment and continued unaffected work retain authority evidence.
|
||||||
|
- Standard scope roles are narrowed by registration and parent/agent ceilings.
|
||||||
|
A role label alone cannot grant access to other workspaces or self-acceptance.
|
||||||
|
- Each managed action can be traced to a session, execution attempt, task,
|
||||||
|
authority decision, and evidence-backed outcome, including interrupted work.
|
||||||
|
|
||||||
|
Later tests must cover strict schemas, cross-project references, path escape,
|
||||||
|
concurrent launch, shared-file races, stale messages, audit write failures,
|
||||||
|
and partial external effects. Terminal/desktop/web clients need situational
|
||||||
|
and accessibility tests when implemented; none are claimed by this draft.
|
||||||
|
CI runners remain owner-deferred. Local suites plus `verify.sh` are the repo
|
||||||
|
publication gate, with exact-candidate review and owner phase approval.
|
||||||
|
|
||||||
|
No deployment or migration occurs now, so existing operation is unchanged.
|
||||||
|
Any later migration must preserve old sessions/run records, be explicit about
|
||||||
|
reset/prune effects, and have an owner-reviewed recovery and rollback test.
|
||||||
|
|
||||||
|
## 10. First owner review
|
||||||
|
|
||||||
|
Review R1-R33 for faithful intent. The owner authorized the grill-me interview
|
||||||
|
on 2026-09-06. Rounds 1-6, Q1-Q26, are recorded in the linked discussion.
|
||||||
|
Q25/Q26 add affected-work reconciliation after approved plan changes and
|
||||||
|
standard scope permission roles with registration-specific narrowing.
|
||||||
|
|
||||||
|
Jason confirmed the behavior summary with "That looks correct" on 2026-09-06,
|
||||||
|
after Q1-Q26. Shared understanding of intended behavior is confirmed. This is
|
||||||
|
not whole-schema approval or a claim that every technical branch is resolved.
|
||||||
|
|
||||||
|
Jason subsequently answered yes to phase 2: detailed records, permissions,
|
||||||
|
commands, and audit guarantees with read-only technical investigation. The
|
||||||
|
linked phase-2 candidate records the first source/document findings and proposed
|
||||||
|
common types. Jason answered Q27 A: invocation-level command evidence with
|
||||||
|
enforced limits, without a promise to enumerate every internal effect.
|
||||||
|
The explicit /goal request resumed phase 2 after reboot recovery. The schema
|
||||||
|
package now includes checked record/command shapes and semantic-rule proposals.
|
||||||
|
Jason answered Q28 A, recorded as R34. The owner-review package now reconciles
|
||||||
|
D1-D16 against the candidate schemas/rules and explicit implementation proof gates.
|
||||||
|
Phase-2 acceptance remains Jason's decision. No mapping, independent gap review,
|
||||||
|
implementation, publication, or migration follows automatically.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Fleet goal ownership
|
||||||
|
|
||||||
|
## Authority and acceptance
|
||||||
|
|
||||||
|
Jason confirmed Resume's NG footer and Alt+G, authorized fleet-wide correction, and authorized scoped commits after green suites. Joe/Huey quiet waits are "looking good so far", not final acceptance. Dewey owns this bounded work in mosaic-stack-dev-test. Independent read-only Pi review must approve exact source and deployment pins. CURRENT and the foundation work remain darkwing's.
|
||||||
|
|
||||||
|
Outcome: one shared NG implementation at ~/.mosaic/.pi/extensions/goal for existing fleet launch configurations, without duplicate registration. Retain wrapper-guard or mosaic-core role enforcement as configured, and the launcher's unslop hook. Remove the Resume-specific no-discovery override once ordinary discovery works. No automatic restarts, credentials/configuration edits, private-state reads, state migration, policy changes, or push. Local-first deployment remains the operator-authorized workflow; repository CI runners remain deferred, not reported green.
|
||||||
|
|
||||||
|
## Measured ownership
|
||||||
|
|
||||||
|
41 agent settings paths and 14 role settings paths reference fleet/extensions/goal. They resolve to 24 ordinary settings files; many agent settings are symlinks to role settings. All declared packages lists are empty. Most seat workdirs are the shared brain; Topher and Velma use their seat directories. Topher's extension directory and fifteen role extension links already resolve to shared extensions. Velma still has an ordinary standalone goal tree. The settings template also references fleet/extensions/goal.
|
||||||
|
|
||||||
|
Pi 0.85.1 package-manager toResolvedPaths canonicalizes resource paths before deduplication. We will verify this mechanism in the native loader with normal discovery enabled. Replacing settings would needlessly touch 24 files and still leave compatibility entrypoints. Instead, preserve every settings file/link and make the legacy fleet goal path and Velma standalone goal path compatibility symlinks to the shared NG goal. Keep their old ordinary source trees in verified backups. No recursive library forwarding and no separately auto-discovered core entrypoint are introduced.
|
||||||
|
|
||||||
|
## State boundary
|
||||||
|
|
||||||
|
The shared implementation resolves its NG state directory relative to the selected import path. Native fixture writes proved Pi deduplicates real source paths but Jiti retains the selected alias when resolving imports. At the brain cwd, state is ~/.mosaic/.pi/state/goal. At Topher or Velma's cwd, their project alias selects their own .pi/state/goal. A role cwd behaves the same way. With no project discovery, the legacy compatibility alias selects ~/.mosaic/fleet/state/goal. Every filename remains goal-state.<incarnation>.json. Do not claim all aliases use one state directory.
|
||||||
|
|
||||||
|
Legacy agent-home goal state remains untouched. Velma and Topher's existing NG stores remain in place. Source unification does not adopt, resume, delete or migrate existing goals. Existing processes keep their loaded code until the operator safely restarts them. Old goal evidence stays in its original location; do not assume an active goal will transfer. Native fixtures use fresh owned incarnation IDs and clean only their exact fixture files. Live canaries never invoke goal commands or read private state.
|
||||||
|
|
||||||
|
The first Velma synthetic fixture failed because it omitted Velma's existing neighboring mosaic-core libraries. The corrected fixture includes those libraries. All shared supporting modules match both installed legacy and Velma libraries byte-for-byte. Pin these dependency trees, wrapper and unslop entrypoints as well as the goal sources, since an alias does not redirect neighboring imports.
|
||||||
|
|
||||||
|
## Delivery
|
||||||
|
|
||||||
|
1. Pin all three source trees, the current Resume-hotfix launcher, and settings references/link targets. Pin launch.env filesystem identity/change metadata only. Environment files may contain credentials, so the deployment tool must never read or copy their contents. Register an issue and append session/build records.
|
||||||
|
2. Add an incident-scoped, fail-closed deployment tool and synthetic tests. Default is preflight. Stage two compatibility symlinks outside discovery, then atomically exchange them with the two ordinary trees. Restore the exact original shared launcher as the third change. Check pins before every effect. On failure reverse verified exchanges; on drift refuse rollback. Retain backup receipts and support explicit verified rollback. Never rewrite a run record.
|
||||||
|
3. Native red control must reproduce the duplicate with ordinary separate trees. Green fixture must load one shared goal through ambient plus explicit aliases, including root, role-linked, Topher-linked, Velma-linked and isolated cwd cases. Preserve configured wrapper/core tool interception plus unslop. Verify NG footer/full recall against identical installed runtime sources. Test hostile drift, symlink/special-file refusals and partial-failure rollback.
|
||||||
|
4. Independently review exact candidate, hashes, tests, state limits and rollback. Deploy locally only after approval, then rerun actual fleet resource combinations with normal discovery. Do not invoke auth-seeding launchers or restart sessions for testing.
|
||||||
|
5. Run goal, native, package and five repository suites. Stage only our explicit files, inspect the index, commit with Dewey identity, verify the commit. Do not stage foundation/skill/task dirt or overwrite CURRENT. No push authorized by this commit request.
|
||||||
|
6. Record Resume acceptance and fleet deployment distinctly. Close #57 for accepted Resume correction when evidence is recorded. Quiet-wait #56 and fleet user acceptance remain open until explicitly accepted.
|
||||||
|
|
||||||
|
The first independent review requested removal of launch.env content hashing because environment files can contain credentials. The corrected candidate pins only filesystem metadata and includes tests that reject any attempted environment-content read and detect metadata drift. The rejected manifest remains historical evidence and was never deployed.
|
||||||
|
|
||||||
|
## Deployment checkpoint, 2026-09-06 09:02 UTC
|
||||||
|
|
||||||
|
Corrected independent review APPROVE: .pi/evidence/goal58/review-v2.log. Deployed the reviewed three-path transaction and verified all after-pins and backups. Legacy fleet goal and Velma goal are now compatibility symlinks to shared NG. The shared launcher is byte-identical to its original pre-workaround version, SHA256 9352feed0acf9d449c26c0556ba00aba1c65ded43d376930a39ff6b5cee21986. No Resume-specific no-discovery override remains.
|
||||||
|
|
||||||
|
Reviewed plan SHA256: 43eb7821a484796850e5e9352f51fe8a6cffb2fb9084769070f449b529a47220. Backup and recovery receipt: /home/jwoltje/.mosaic/.pi/goal-backups/goal58-5baaf9ff598644c6ae364b297e3c982a. Its planned.json contains the complete source, dependency and configuration pins. The former plan-v2.json was rejected and never deployed.
|
||||||
|
|
||||||
|
Native negative control and six unified fixture cases pass, including owned fixture writes proving the state paths above. Seventeen transaction controls cover rollback, partial interruption, source/config/dependency/mode drift, symlink/special-file refusal, concurrent-writer lock refusal and the environment-content no-read boundary. Post-install native loading passed all 55 actual agent/role settings combinations with their configured wrapper or core tool interception and unslop. No live goal command or model turn ran. Evidence: .pi/evidence/goal58/live-matrix.log. This verifies registration/discovery, not every role-policy decision at runtime.
|
||||||
|
|
||||||
|
All 71 goal tests, 18 package controls, native footer/full recall and timed/untimed waiting checks pass. Repository suites pass: config 24, task 90, release 14, conductor 17, auth 15. Whitespace and prose checks pass. Static TypeScript checking and remote CI remain unavailable/deferred, not certified by these tests.
|
||||||
|
|
||||||
|
Explicit rollback, only if current source, dependency, configuration and backup pins still match:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/unify-fleet-goal.py rollback \
|
||||||
|
--plan /home/jwoltje/.mosaic/.pi/goal-backups/goal58-5baaf9ff598644c6ae364b297e3c982a/planned.json \
|
||||||
|
--sha256 43eb7821a484796850e5e9352f51fe8a6cffb2fb9084769070f449b529a47220 \
|
||||||
|
--backup /home/jwoltje/.mosaic/.pi/goal-backups/goal58-5baaf9ff598644c6ae364b297e3c982a
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback restores both old source trees and the previously accepted Resume workaround. Do not overwrite later concurrent changes to force rollback.
|
||||||
|
|
||||||
|
User test: at a safe stopping point, restart another seat through its normal launch command. Expect one NG footer, full /goal and Alt+G recall, no duplicate-tool startup error, and the same configured safeguards. Do not overwrite an existing assignment with a test goal. Old legacy state does not transfer automatically. No session was restarted by Dewey. Resume UX is accepted; fleet rollout is ready for user test and #56 quiet-wait feedback is still preliminary.
|
||||||
|
|
||||||
|
Commit scope excludes CURRENT, foundation planning/reviews, unrelated skills/tasks, and the shared logs because they contain other-owner uncommitted entries. Our evidence/acceptance checkpoints are in these owned plans; shared append-only records remain on disk. MS58-DW-1 informational notice to darkwing returned rc=2, submission unconfirmed; no blind resend and no reply requested. Last verified index release remains MS55-DW-3; inspect the index and commit only explicit owned paths. No push authorized. Issue #58. Evidence directory: .pi/evidence/goal58/.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user