@jossuealcala/madre 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ Todas las versiones publicadas de `@jossuealcala/madre`. Fechas en ISO.
4
4
 
5
5
  Una versión se cierra cuando está en npm: hasta entonces su sección se llama **Sin publicar** y puede crecer. Cada versión publicada tiene exactamente una etiqueta `vX.Y.Z`, una release en GitHub y una sección aquí; el parche puede llegar a dos dígitos (`0.2.10`) antes de subir el menor. Ver `docs/ROADMAP.md` para el criterio de qué sube cada número.
6
6
 
7
+ ## 0.3.2 · 2026-09-19
8
+
9
+ ### Modos y permisos, una sola lógica
10
+ - `#2 CREATE` ya no encierra al agente en `.pulse/out/`: crea archivos y carpetas nuevos donde corresponda en el proyecto, según sus convenciones, con `.pulse/out/<turno>/` como borrador. MADRE fotografía el proyecto antes del turno; lo que apareció se conserva y se muestra como artefacto, y todo archivo previo modificado, renombrado o borrado se restaura y se avisa (`create.reverted`). Las CLIs reciben sus herramientas de escritura sobre el proyecto (Claude Code y OpenCode solo escriben si también pueden editar, verificado con los CLIs reales); la garantía de "solo añadir" la da la restauración de MADRE al terminar.
11
+ - Un orquestador puede pedir el modo de cada paso de su plan: `@codex #2: …`, `@claude #3: …`. MADRE lo acota al modo del mensaje del humano y al `MAX MODE` del agente. Bajo `#3` la palabra del orquestador basta: un paso `#2` recibe su lease de proyecto y un paso `#3` toma CONTROL para su turno, con checkpoint propio. Bajo `#1` un paso `#2` sigue pasando por la escalación.
12
+ - CONNECTIONS se reduce a dos controles por agente, `MAX MODE` y `DEFAULT MODE` (`#1` o `#2`), más dos habilidades, imágenes y web. Los interruptores `CREATE FILES` y `ALWAYS · STANDING LEASE` desaparecen; las configuraciones viejas (`write: false`, `alwaysCreate`) se siguen leyendo como `MAX MODE #1` y `DEFAULT MODE #2`.
13
+ - Los checkpoints funcionan en proyectos sin git: MADRE usa un repositorio sombra propio fuera del proyecto, así `#2` y `#3` tienen la misma reversibilidad en cualquier carpeta.
14
+
15
+ ### Permisos que sí se cumplen
16
+ - OpenCode nunca había podido escribir en CREATE ni en CONTROL: sus reglas de permiso se comparan con rutas relativas al proyecto, no absolutas, y crear un archivo es la herramienta `write`, distinta de `edit`. Verificado contra opencode 1.18.4 con proyectos reales, con y sin espacios en la ruta. Ahora CREATE permite `write` y `edit` solo dentro de la carpeta del turno, y CONTROL permite todo el proyecto menos `.git/`, `.pulse/`, `.madre/`, los `.env` y `.claude/settings.local.json`.
17
+ - El checkpoint de CONTROL fotografía también los repositorios git anidados dentro del proyecto (un monorepo de sitios, cada uno con su `.git`): antes un cambio dentro de uno de ellos era invisible, la sala decía "no cambió nada" y UNDO no lo deshacía. Ahora la lista de cambios, las zonas prohibidas y UNDO cubren cada repositorio, con rutas relativas al proyecto. La fotografía se toma con `ls-files` y no con `add`, así un repo anidado sin commits ya no la rompe.
18
+
7
19
  ## 0.3.1 · 2026-09-19
8
20
 
9
21
  ### Canal de liberación: la sala avisa cuando hay versión nueva
package/README.md CHANGED
@@ -85,16 +85,18 @@ Cada mensaje sale con un modo. Tu modo es el techo de cualquier plan que ese men
85
85
  |---|---|---|
86
86
  | `#0` | **GHOST** | Fuera del registro. No se escribe en el ledger, nadie lo recuerda, desaparece al recargar. Los tokens sí cuentan. |
87
87
  | `#1` | **EXCHANGE** | Leer el proyecto y coordinar. El predeterminado. |
88
- | `#2` | **CREATE** | Crear archivos e imágenes, solo dentro de `<proyecto>/.pulse/out/<fecha>-<id>/`. |
88
+ | `#2` | **CREATE** | Añadir archivos y carpetas nuevos donde corresponda en el proyecto. Lo que ya existía no cambia: si un agente lo toca, MADRE lo restaura al terminar y lo dice. |
89
89
  | `#3` | **CONTROL** | Editar el proyecto real sin aprobación por acción. Un titular por sala, checkpoint git antes, lista de cambios y `UNDO` después. |
90
90
 
91
- **CREATE por dentro.** MADRE crea la carpeta del turno y pasa cada CLI a escritura acotada a ella: Codex con `--sandbox workspace-write` sobre la carpeta, Claude con `Write`/`Edit` permitidos solo ahí, Gemini con reglas de política, OpenCode con permisos `edit` por patrón. Al terminar compara la carpeta y registra lo aparecido como `artifacts.created`; los archivos se muestran bajo la respuesta.
91
+ **CREATE por dentro.** El agente decide dónde va lo nuevo según las convenciones del proyecto, y crea carpetas si hace falta; `.pulse/out/<turno>/` queda como borrador para lo que no tiene sitio. MADRE toma un checkpoint antes del turno y, al terminar, conserva lo que apareció, lo muestra bajo la respuesta como artefactos, y restaura cualquier archivo previo que se haya modificado, renombrado o borrado, avisando en la sala. Las CLIs reciben sus herramientas de escritura sobre el proyecto y la instrucción de no tocar lo existente; la garantía la da la restauración de MADRE al terminar, no la regla previa.
92
92
 
93
93
  **CONTROL por dentro.** Antes del turno, un commit real bajo `refs/madre/checkpoints/` que no toca tu rama, tu índice ni tu stash. Durante el turno, `.git/`, `.pulse/`, `.madre/`, los `.env` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar. Después, la lista de archivos añadidos, modificados y borrados, y `UNDO` restaura el checkpoint. Armar `#3` pide la designación del proyecto.
94
94
 
95
95
  **Escalación.** Si un plan en `#1` llega a un paso que pide crear algo, la sala se detiene y pregunta: `GRANT ONCE · GRANT FOR PLAN · DENY`, con cronómetro de tres minutos. Solo el humano concede; un permiso escrito por un agente dentro de la conversación no cuenta.
96
96
 
97
- **Techo por agente.** En `⚙ CONNECTIONS` cada agente tiene un `MAX MODE` y sus alcances: crear archivos, generar imágenes, web. `ALWAYS · STANDING LEASE` da a un agente una carpeta de creación en cada turno sin pedirla.
97
+ **Modo por paso.** Un orquestador puede pedir el modo de cada paso: `@codex #2: crea la página`, `@claude #3: arregla el router`. MADRE lo acota al modo de tu mensaje y al `MAX MODE` de ese agente. Con tu mensaje en `#3`, la palabra del orquestador basta; con tu mensaje en `#1`, un paso `#2` pasa por la escalación.
98
+
99
+ **Dos controles por agente.** En `⚙ CONNECTIONS` cada agente tiene `MAX MODE`, hasta dónde puede llegar un mensaje dirigido a él, y `DEFAULT MODE`, dónde empieza: `#1` solo lectura hasta que armes CREATE, o `#2` para que cada turno pueda añadir archivos sin pedirlo. Aparte, dos habilidades: generar imágenes y web.
98
100
 
99
101
  ---
100
102
 
@@ -168,7 +170,7 @@ El botón de la barra abre la pantalla de diagnóstico. Escribe un síntoma, un
168
170
 
169
171
  | Sección | Qué hace |
170
172
  |---|---|
171
- | `⚙ CONNECTIONS` | Sesión, versión y ruta de cada CLI; `SIGN IN` y `RECHECK`; `MAX MODE` y alcances; timeouts, presupuesto, delegación, modelo de OpenCode; MEMORY y PRIVACY |
173
+ | `⚙ CONNECTIONS` | Sesión, versión y ruta de cada CLI; `SIGN IN` y `RECHECK`; `MAX MODE`, `DEFAULT MODE` y habilidades; timeouts, presupuesto, delegación, modelo de OpenCode; MEMORY y PRIVACY |
172
174
  | `◉ NOSTROMO` | El mapa de la memoria |
173
175
  | `SENTINEL` | Fallos que ninguna condición explica y caídas del proceso, con rutas, usuarios, correos y claves eliminados. Cada reporte tiene `REPORT ON GITHUB ↗` para leerlo antes de publicarlo; `AUTO-REPORT`, apagado por defecto, envía los nuevos al colector del proyecto |
174
176
  | `RELEASE CHANNEL` | Una consulta a npm al día. Si hay versión nueva, una alerta en la barra y aquí el comando exacto para cómo corre tu copia. MADRE nunca se actualiza sola |
@@ -198,7 +200,7 @@ Cada módulo es un archivo en `src/modules/` declarado con `defineModule`. Cómo
198
200
  - **Nada por sí solo.** MADRE no tiene nube, cuenta ni backend. No guarda credenciales.
199
201
  - **Lo que un agente lee, viaja a su proveedor.** Codex a OpenAI, Claude Code a Anthropic, Gemini CLI a Google, OpenCode a quien tenga configurado. Aplican su cuenta, sus límites y sus términos. `@madre` y el archivista con Ollama no salen de la máquina.
200
202
  - **Dos envíos propios, ambos bajo tu interruptor.** El sentinel, apagado por defecto, envía reportes redactados al colector del proyecto. El canal de liberación, encendido por defecto, pregunta a npm por la última versión: viaja el nombre del paquete, nada más, la misma petición que hace `npx`. `PULSE_UPDATE_CHECK=0` lo apaga.
201
- - **Escritura.** En `#1` nadie escribe. En `#2` solo dentro de `.pulse/out/`. En `#3` todo el proyecto salvo las zonas prohibidas, con checkpoint y `UNDO`.
203
+ - **Escritura.** En `#1` nadie escribe. En `#2` solo se añade: lo que existía se restaura al terminar el turno. En `#3` todo el proyecto salvo las zonas prohibidas, con checkpoint y `UNDO`. En un proyecto sin git, MADRE guarda sus fotografías en un repositorio sombra fuera del proyecto.
202
204
  - **Memoria.** Todo lo dicho fuera de GHOST queda en `~/.pulse/rooms/<sala>/` y vuelve a los prompts de todos los agentes de esa sala. GHOST es la salida para lo que no debe recordarse; PRIVACY, para los nombres que nunca deben aparecer.
203
205
 
204
206
  ---
package/docs/INTERNALS.md CHANGED
@@ -62,9 +62,15 @@ Cada agente corre como proceso en su propio grupo, con un home aislado que solo
62
62
 
63
63
  Los alcances de imagen de Claude, Gemini y OpenCode se cumplen con **Image Studio**: el servidor MCP `src/mcp/image-server.mjs`, adjunto solo dentro de un lease con el alcance encendido, que genera con la API de imágenes de Gemini y guarda el archivo en la carpeta del turno.
64
64
 
65
+ ## CREATE por dentro
66
+
67
+ Un turno en `#2` toma la misma fotografía que CONTROL, sin asiento: varios turnos `#2` pueden correr a la vez. Al terminar, `diff` contra la fotografía: los archivos añadidos se conservan y se registran como `artifacts.created` con su ruta en el proyecto; los modificados, renombrados o borrados se restauran desde la fotografía y se anuncian en `create.reverted`. Las zonas prohibidas se restauran igual que en CONTROL. Cada CLI recibe sus herramientas de escritura sobre el proyecto: Claude Code niega `Write` si `Edit` no está permitido en la misma ruta y OpenCode deja sin herramienta de escritura si `edit` está denegado (ambos verificados con el CLI real), así que en `#2` van juntas y la garantía de "solo añadir" es la restauración posterior, no la regla previa. Gemini recibe solo `write_file`. El `scratchDir` bajo `.pulse/out/` sigue existiendo para lo que no tiene sitio y para Image Studio.
68
+
69
+ En un plan, el modo de cada paso es el mínimo entre lo que pidió el orquestador (`@agente #n:`), el techo del plan (el modo del mensaje humano) y el `MAX MODE` del agente. Bajo techo `#3`, un paso `#2` recibe un lease de proyecto emitido a nombre del orquestador y un paso `#3` toma CONTROL con su propio checkpoint, uno a la vez. Bajo techo `#1`, un paso `#2` pasa por la escalación al humano.
70
+
65
71
  ## CONTROL por dentro
66
72
 
67
- Antes del turno, MADRE fotografía el proyecto como un commit real bajo `refs/madre/checkpoints/` con un índice temporal: incluye archivos sin seguimiento, respeta `.gitignore`, no toca tu rama, tu índice ni tu stash, y funciona en repositorios sin commits. Mientras dura el turno, `.env*`, `.pulse/`, `.madre/` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar. Después, `git diff` contra el checkpoint da la lista de cambios; cualquier escritura en una zona prohibida que hubiera pasado se revierte desde el checkpoint y se nombra. `UNDO` restaura el checkpoint completo.
73
+ Antes del turno, MADRE fotografía el proyecto como un commit real bajo `refs/madre/checkpoints/` con un índice temporal (en un proyecto sin git, en un repositorio sombra bajo el directorio temporal del sistema, con el proyecto como árbol de trabajo): incluye archivos sin seguimiento, respeta `.gitignore`, no toca tu rama, tu índice ni tu stash, y funciona en repositorios sin commits. Mientras dura el turno, `.env*`, `.pulse/`, `.madre/` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar. Después, `git diff` contra el checkpoint da la lista de cambios; cualquier escritura en una zona prohibida que hubiera pasado se revierte desde el checkpoint y se nombra. `UNDO` restaura el checkpoint completo.
68
74
 
69
75
  ## Recuperación operativa
70
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jossuealcala/madre",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "MADRE · one local room where the AI coding agents already on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on a project together, with a shared memory every one of them recalls and a local @madre that speaks for it. Read-only by default, per-message permission modes up to a checkpointed CONTROL. Nothing leaves your machine on its own.",
5
5
  "keywords": [
6
6
  "ai-agents",
package/public/app.js CHANGED
@@ -33,14 +33,14 @@ const els = {
33
33
  // Built-in commands live in the composer; module commands come from the
34
34
  // server (/api/commands) and run there, read-only, as fact cards.
35
35
  const CLIENT_COMMANDS = [
36
- { name: 'create', title: 'CREATE', usage: '/create <what to make>', summary: 'Arm the creation lease for this message: the agent may create files inside .pulse/out/.', available: true, client: true },
36
+ { name: 'create', title: 'CREATE', usage: '/create <what to make>', summary: 'Arm CREATE for this message: the agent may add new files to the project where they belong.', available: true, client: true },
37
37
  { name: 'image', title: 'Image', usage: '/image <what to draw>', summary: 'Ask for an image: arms CREATE with the image scope and routes to an agent that can generate images.', available: true, client: true },
38
38
  { name: 'stopall', title: 'STOP ALL', usage: '/stopall', summary: 'Master brake: halt every plan and turn in flight. Never reaches an agent.', available: true, client: true },
39
39
  ];
40
40
 
41
41
  const PLACEHOLDERS = {
42
42
  plain: 'Type here, human. Ask the room…',
43
- create: 'Creation lease on: what to create and how it should look. It lands in .pulse/out/',
43
+ create: 'CREATE on: what to make. New files land where they belong in the project; nothing existing changes.',
44
44
  order: 'Priority one. Terse transmissions; the original stays in the record.',
45
45
  ghost: 'Off the record. Ask anything; nothing is saved, nobody else will remember it.',
46
46
  control: 'Control armed. Say what to change in the project; every action runs without asking.',
@@ -55,7 +55,7 @@ const MAX_ROWS = 3;
55
55
  const MODES = {
56
56
  0: { key: 'ghost', label: 'GHOST', hint: 'Off the record. Nothing is saved; gone on reload.' },
57
57
  1: { key: 'exchange', label: 'EXCHANGE', hint: 'Read the project and talk to the room. Writes nothing.' },
58
- 2: { key: 'create', label: 'CREATE', hint: 'Write new files, only inside this turn\'s .pulse/out/.' },
58
+ 2: { key: 'create', label: 'CREATE', hint: 'Add new files where they belong in the project. Existing files stay untouched.' },
59
59
  3: { key: 'control', label: 'CONTROL', hint: 'Edit the project itself, no approval per action. Override required.' },
60
60
  };
61
61
 
@@ -1426,6 +1426,18 @@ function renderLeaseRefused(event) {
1426
1426
 
1427
1427
  /* ---------- CONTROL: the project itself, between two checkpoints ---------- */
1428
1428
 
1429
+ // create · @codex · 2 existing files put back: CREATE only adds (README.md, src/index.astro)
1430
+ function renderCreateReverted(event) {
1431
+ const { agent, existing = [], forbidden = [] } = event.payload;
1432
+ const node = el('div', 'system memory warn');
1433
+ node.style.setProperty('--agent', agentColor(agent));
1434
+ node.append('create · ', el('b', 'who', `@${agent}`));
1435
+ if (existing.length) node.append(` · ${existing.length} existing file${existing.length === 1 ? '' : 's'} put back, CREATE only adds: ${existing.slice(0, 4).join(', ')}${existing.length > 4 ? '…' : ''}`);
1436
+ if (forbidden.length) node.append(` · ${forbidden.length} write${forbidden.length === 1 ? '' : 's'} into forbidden zones reverted`);
1437
+ node.append(' · need to change existing files? ask again in #3 CONTROL');
1438
+ return node;
1439
+ }
1440
+
1429
1441
  function renderControlStarted(event) {
1430
1442
  const { agent, commit, message } = event.payload;
1431
1443
  const node = el('div', 'system control');
@@ -1552,7 +1564,7 @@ function renderModeRequest(event) {
1552
1564
  for (const other of actions.querySelectorAll('button')) other.disabled = false;
1553
1565
  }
1554
1566
  };
1555
- const once = el('button', 'grant', 'GRANT ONCE'); once.type = 'button'; once.title = `@${agent} creates files for this step only, inside its own .pulse/out/ directory.`;
1567
+ const once = el('button', 'grant', 'GRANT ONCE'); once.type = 'button'; once.title = `@${agent} may add files to the project for this step only.`;
1556
1568
  const plan = el('button', 'grant plan', 'GRANT FOR PLAN'); plan.type = 'button'; plan.title = 'Every remaining writable step of this plan shares one lease directory.';
1557
1569
  const deny = el('button', 'deny', 'DENY'); deny.type = 'button'; deny.title = `@${agent} answers read-only and says what it would have created.`;
1558
1570
  once.addEventListener('click', () => decide('once', once));
@@ -1594,12 +1606,19 @@ function renderLease(event) {
1594
1606
  const { agent, outDir, scopes = [], unavailable = [], standing = false, escalated = null } = event.payload;
1595
1607
  const node = el('div', 'system lease');
1596
1608
  node.style.setProperty('--agent', agentColor(agent));
1597
- node.append(el('b', null, standing ? 'standing lease · ' : escalated ? `lease granted on request${escalated === 'plan' ? ' · whole plan' : ''} · ` : 'creation lease · '));
1598
- node.append(`@${agent} may ${scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') || 'create files'}${unavailable.length ? ` (cannot ${unavailable.join(', ')})` : ''} in `);
1599
- const link = el('a', 'file-link', outDir);
1600
- link.href = '#';
1601
- link.addEventListener('click', (ev) => { ev.preventDefault(); });
1602
- node.append(link);
1609
+ const { delegated = false, scratchDir = null, grantedBy = null } = event.payload;
1610
+ node.append(el('b', null, standing ? 'default #2 · ' : escalated ? `#2 granted on request${escalated === 'plan' ? ' · whole plan' : ''} · ` : delegated ? `#2 by @${grantedBy} · ` : 'create · '));
1611
+ node.append(`@${agent} may ${scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') || 'create files'}${unavailable.length ? ` (cannot ${unavailable.join(', ')})` : ''} `);
1612
+ if (outDir === '.' || !outDir) {
1613
+ node.append('anywhere in the project · existing files stay untouched');
1614
+ if (scratchDir) { node.append(' · scratch '); const link = el('a', 'file-link', scratchDir); link.href = '#'; link.addEventListener('click', (ev) => { ev.preventDefault(); }); node.append(link); }
1615
+ } else {
1616
+ node.append('in ');
1617
+ const link = el('a', 'file-link', outDir);
1618
+ link.href = '#';
1619
+ link.addEventListener('click', (ev) => { ev.preventDefault(); });
1620
+ node.append(link);
1621
+ }
1603
1622
  state.lastSender = null;
1604
1623
  return node;
1605
1624
  }
@@ -1861,6 +1880,7 @@ function renderEventNode(event) {
1861
1880
  case 'lease.missing': node = renderLeaseMissing(event); break;
1862
1881
  case 'mode.requested': node = renderModeRequest(event); break;
1863
1882
  case 'control.started': node = renderControlStarted(event); break;
1883
+ case 'create.reverted': node = renderCreateReverted(event); break;
1864
1884
  case 'control.changed': node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
1865
1885
  case 'control.reverted': node = renderControlReverted(event); break;
1866
1886
  case 'mode.granted':
@@ -2351,8 +2371,8 @@ function renderCreateScopes() {
2351
2371
  const standing = Boolean(scopes?.write?.always);
2352
2372
  els.createToggle.classList.toggle('standing', standing);
2353
2373
  els.createToggle.title = standing
2354
- ? `Standing lease: every turn of @${id} may create files inside .pulse/out/ (set in CONNECTIONS). Arming CREATE is not needed.`
2355
- : 'Creation lease: let the agent create files for this request, only inside .pulse/out/';
2374
+ ? `@${id} starts in #2 (DEFAULT MODE in CONNECTIONS): every turn may add files to the project. Arming CREATE is not needed.`
2375
+ : 'CREATE: let the agent add new files to the project for this request; existing files stay untouched';
2356
2376
  box.hidden = !state.create || !scopes;
2357
2377
  if (box.hidden) return;
2358
2378
  box.replaceChildren();
@@ -3298,7 +3318,8 @@ function connectionCard(agent) {
3298
3318
  card.append(meta);
3299
3319
  const scopes = el('div', 'scopes');
3300
3320
  const agentScopes = settingsUI.data.settings.capabilities?.[agent.id]?.scopes ?? {};
3301
- for (const [key, labelText] of [['write', 'CREATE FILES'], ['imageGen', 'GENERATE IMAGES'], ['web', 'WEB ACCESS']]) {
3321
+ // Two abilities per agent: images and web. Writing is not an ability, it is the ceiling below.
3322
+ for (const [key, labelText] of [['imageGen', 'GENERATE IMAGES'], ['web', 'WEB ACCESS']]) {
3302
3323
  const scope = agentScopes[key] ?? { capable: false, enabled: false, wired: false };
3303
3324
  const line = el('label', `scope${!scope.capable || !scope.wired ? ' unavailable' : ''}`);
3304
3325
  const box = el('input'); box.type = 'checkbox'; box.checked = Boolean(scope.enabled); box.disabled = !scope.capable || !scope.wired;
@@ -3332,30 +3353,6 @@ function connectionCard(agent) {
3332
3353
  line.append(assist);
3333
3354
  }
3334
3355
  scopes.append(line);
3335
- if (key === 'write' && scope.capable) {
3336
- const always = el('label', `scope sub${scope.enabled ? '' : ' unavailable'}`);
3337
- const alwaysBox = el('input'); alwaysBox.type = 'checkbox'; alwaysBox.checked = Boolean(scope.always); alwaysBox.disabled = !scope.enabled;
3338
- alwaysBox.dataset.agent = agent.id; alwaysBox.dataset.scope = 'alwaysCreate'; alwaysBox.className = 'scope-input';
3339
- alwaysBox.addEventListener('change', async () => {
3340
- alwaysBox.disabled = true;
3341
- try {
3342
- const response = await fetch('/api/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ scopes: { [agent.id]: { alwaysCreate: alwaysBox.checked } } }) });
3343
- const result = await response.json().catch(() => ({}));
3344
- if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
3345
- if (result.settings?.capabilities) { state.capabilities = result.settings.capabilities; settingsUI.data.settings.capabilities = result.settings.capabilities; renderCreateScopes(); }
3346
- toast(`MU/TH/UR › @${agent.id} ${alwaysBox.checked ? 'now holds a standing lease: every turn may create files in .pulse/out/, including plan steps.' : 'creates files only when you arm CREATE.'}`);
3347
- } catch (error) {
3348
- alwaysBox.checked = !alwaysBox.checked;
3349
- toast(`Scope was not saved: ${error.message}`);
3350
- } finally {
3351
- alwaysBox.disabled = !scope.enabled;
3352
- }
3353
- });
3354
- always.append(alwaysBox, 'ALWAYS · STANDING LEASE');
3355
- always.append(el('span', 'why', scope.always ? 'every turn and plan step may create files' : 'only when CREATE is armed'));
3356
- always.title = 'Skip arming CREATE: this agent gets a fresh .pulse/out/ directory on every turn, also when another agent delegates to it.';
3357
- scopes.append(always);
3358
- }
3359
3356
  }
3360
3357
  // The ceiling: how far the composer may take this agent.
3361
3358
  const ceiling = el('div', 'ceiling');
@@ -3381,6 +3378,29 @@ function connectionCard(agent) {
3381
3378
  ceiling.append(seg);
3382
3379
  ceiling.append(el('span', 'why', `${MODES[currentCap].label} · ${MODES[currentCap].hint}`));
3383
3380
  scopes.append(ceiling);
3381
+ // The start: where a message to this agent begins. #2 means every turn may create files without arming CREATE.
3382
+ const start = el('div', 'ceiling');
3383
+ start.append(el('span', 'k', 'DEFAULT MODE'));
3384
+ const startSeg = el('div', 'seg');
3385
+ const currentStart = agentScopes.defaultMode ?? 1;
3386
+ for (const n of [1, 2]) {
3387
+ const button = el('button', `seg-option o${n}${currentStart === n ? ' current' : ''}`, `#${n}`);
3388
+ button.type = 'button';
3389
+ button.title = n === 2 ? 'Every message to this agent starts in CREATE: new files where they belong, existing files untouched. Plan steps to it too.' : 'Messages start read-only; arm CREATE when you want files.';
3390
+ button.disabled = n > currentCap;
3391
+ button.addEventListener('click', async () => {
3392
+ if (n === currentStart) return;
3393
+ for (const other of startSeg.children) other.disabled = true;
3394
+ try {
3395
+ await saveSettingNow({ scopes: { [agent.id]: { defaultMode: n } } }, n === 2 ? `@${agent.id} starts in #2 CREATE: every turn may add files to the project.` : `@${agent.id} starts read-only; arm CREATE when you want files.`);
3396
+ await loadSettings();
3397
+ } catch (error) { toast(`Default mode was not saved: ${error.message}`); for (const other of startSeg.children) other.disabled = false; }
3398
+ });
3399
+ startSeg.append(button);
3400
+ }
3401
+ start.append(startSeg);
3402
+ start.append(el('span', 'why', currentStart === 2 ? 'every turn may create files, plan steps too' : 'read-only until you arm CREATE'));
3403
+ scopes.append(start);
3384
3404
  card.append(scopes);
3385
3405
 
3386
3406
  const row = el('div', 'row');
@@ -301,7 +301,7 @@ export const CONDITIONS = [
301
301
  severity: 'informational',
302
302
  title: 'Permission modes: #0 GHOST · #1 EXCHANGE · #2 CREATE · #3 CONTROL',
303
303
  match: /mode|ghost|exchange|control|override|designation|max mode|#[0-3]\b/i,
304
- diagnosis: 'Every message goes out at a mode, chosen in the chip after TO @agent (or typed as #2 in the text). #0 GHOST is off the record: nothing is saved, no other agent remembers it, gone on reload, no delegation. #1 EXCHANGE is the default: read and coordinate. #2 CREATE is the creation lease: files and images inside .pulse/out/. #3 CONTROL puts one agent in command of the project itself: a git checkpoint is taken first, every change is listed afterwards, writes into .git, .pulse or .env files are reverted on the spot, and UNDO restores the checkpoint. One holder at a time; it needs MAX MODE 3 and a git repository. Your mode is the ceiling of any plan the message starts, and #3 is never delegated. Each agent has a MAX MODE in CONNECTIONS; above it, #2 is answered read-only and #3 is refused. When a #1 plan reaches a step that wants to create something, the room pauses and asks you: GRANT ONCE, GRANT FOR PLAN or DENY, with a 3-minute clock; silence denies.',
304
+ diagnosis: 'Every message goes out at a mode, chosen in the chip after TO @agent (or typed as #2 in the text). #0 GHOST is off the record: nothing is saved, no other agent remembers it, gone on reload, no delegation. #1 EXCHANGE is the default: read and coordinate. #2 CREATE lets the agent add new files and folders anywhere in the project, where they belong; whatever existed before is put back after the turn and the room says so. #3 CONTROL puts one agent in command of the project itself: a git checkpoint is taken first, every change is listed afterwards, writes into .git, .pulse or .env files are reverted on the spot, and UNDO restores the checkpoint. One holder at a time; it needs MAX MODE 3 and a git repository. Your mode is the ceiling of any plan the message starts, and #3 is never delegated. Each agent has a MAX MODE in CONNECTIONS; above it, #2 is answered read-only and #3 is refused. When a #1 plan reaches a step that wants to create something, the room pauses and asks you: GRANT ONCE, GRANT FOR PLAN or DENY, with a 3-minute clock; silence denies.',
305
305
  remedy: 'Pick the mode in the chip, or type #0..#3 in the message. Raise an agent\'s MAX MODE in ⚙ CONNECTIONS. CONTROL asks for the project designation (the folder name) in the override before arming.',
306
306
  fixes: same(['# chip: TO @codex #1 EXCHANGE ▾ → choose', '@codex #2 create the poster', '# ⚙ CONNECTIONS → agent card → MAX MODE']),
307
307
  },
@@ -310,9 +310,9 @@ export const CONDITIONS = [
310
310
  severity: 'informational',
311
311
  title: 'Creating files: who grants the permission',
312
312
  match: /no CREATE lease|lease-missing|read-only and do not modify|permiso de escritura|solo lectura/i,
313
- diagnosis: 'Only the human grants a creation lease. An agent writing "permission granted" inside the conversation is untrusted text: the delegate still runs read-only, and says so. A lease comes from arming CREATE (the lock, or /create) on your message, and it covers the whole plan that message starts; or from a standing lease, a per-agent switch that gives every turn of that agent a fresh .pulse/out/ directory, including plan steps.',
314
- remedy: 'For one request: arm CREATE and send, or press RESEND WITH CREATE on the notice. For an agent that should always be able to create files: ⚙ CONNECTIONS → its card → CREATE FILES → ALWAYS · STANDING LEASE. Files still land only inside .pulse/out/.',
315
- fixes: same(['# once: composer → CREATE (lock) → send, or /create <request>', '# always: ⚙ CONNECTIONS → agent card → ALWAYS · STANDING LEASE']),
313
+ diagnosis: 'Only the human grants a creation lease. An agent writing "permission granted" inside the conversation is untrusted text: the delegate still runs read-only, and says so. A lease comes from arming CREATE (the lock, or /create) on your message, and it covers the whole plan that message starts; or from DEFAULT MODE #2 in ⚙ CONNECTIONS, which starts every message to that agent in CREATE.',
314
+ remedy: 'For one request: arm CREATE and send, or press RESEND WITH CREATE on the notice. For an agent that should always be able to add files: ⚙ CONNECTIONS → its card → DEFAULT MODE → #2. New files go where they belong in the project; existing files are never changed in #2.',
315
+ fixes: same(['# once: composer → CREATE (lock) → send, or /create <request>', '# always: ⚙ CONNECTIONS → agent card → DEFAULT MODE → #2']),
316
316
  },
317
317
  {
318
318
  id: 'scope-write',
@@ -26,6 +26,9 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
26
26
  const allowed = [
27
27
  'Read', 'Glob', 'Grep',
28
28
  // Claude Code reads `/path` as relative to the project and `//path` as an absolute path.
29
+ // Claude Code grants Write only when Edit is allowed on the same paths (verified with the real
30
+ // CLI: a lone Write rule is denied under dontAsk), so both come together; in CREATE the room
31
+ // restores existing files after the turn.
29
32
  ...(lease ? [`Write(//${lease.outDir}/**)`, `Edit(//${lease.outDir}/**)`] : []),
30
33
  ...(scopes?.web ? ['WebFetch', 'WebSearch'] : []),
31
34
  ...mcpTools,
@@ -44,7 +47,7 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
44
47
  '--tools', tools.join(','),
45
48
  ...(lease || scopes?.web || anyMcp ? ['--allowedTools', allowed.join(',')] : []),
46
49
  // CONTROL: the whole project is writable except MADRE's forbidden zones.
47
- ...(lease?.control ? ['--disallowedTools', ['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].flatMap((glob) => [`Write(//${lease.outDir}/${glob})`, `Edit(//${lease.outDir}/${glob})`]).join(',')] : []),
50
+ ...(lease?.control || lease?.create ? ['--disallowedTools', ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'].flatMap((glob) => [`Write(//${lease.outDir}/${glob})`, `Edit(//${lease.outDir}/${glob})`]).join(',')] : []),
48
51
  // --safe-mode disables every MCP server, ours included. With a MADRE server
49
52
  // attached we drop it and instead load no setting sources at all: no user
50
53
  // hooks, plugins or MCP servers, only the project's CLAUDE.md and ours.
@@ -27,19 +27,22 @@ export const geminiCredentialFiles = ['oauth_creds.json', 'google_accounts.json'
27
27
  // whose file_path argument starts with the lease directory. Plan mode would
28
28
  // block every write regardless of policy, so a lease uses approval "default":
29
29
  // headless Gemini cannot prompt, so anything the policy does not allow fails.
30
- export function geminiLeasePolicy(outDir, { control = false } = {}) {
30
+ export function geminiLeasePolicy(outDir, { control = false, create = false } = {}) {
31
31
  const escaped = outDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
- const forbidden = control ? `
32
+ // The whole project is the lease in #2 and #3: MADRE's zones stay out of reach.
33
+ const forbidden = control || create ? `
33
34
  [[rule]]
34
35
  toolName = ["write_file", "replace", "edit", "run_shell_command"]
35
- argsPattern = '${escaped}/(\\.git|\\.pulse|\\.env)'
36
+ argsPattern = '${escaped}/(\\.git|\\.pulse|\\.madre|\\.env|\\.claude/settings\\.local\\.json)'
36
37
  decision = "deny"
37
38
  priority = 1100
38
39
  interactive = false
39
40
  ` : '';
41
+ // CREATE (#2) adds files: write_file only; CONTROL (#3) also replaces and edits.
42
+ const tools = create ? '["write_file"]' : '["write_file", "replace", "edit"]';
40
43
  return `${geminiReadonlyPolicy}
41
44
  [[rule]]
42
- toolName = ["write_file", "replace", "edit"]
45
+ toolName = ${tools}
43
46
  argsPattern = '"file_path"\\s*:\\s*"${escaped}/'
44
47
  decision = "allow"
45
48
  priority = 1000
@@ -56,7 +59,7 @@ interactive = false
56
59
  `;
57
60
 
58
61
  export function geminiPolicy({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
59
- return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}`;
62
+ return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}`;
60
63
  }
61
64
 
62
65
  export function geminiMemoryPolicy(memoryServer) {
@@ -34,19 +34,36 @@ export function buildOpenCodeArgs({ projectRoot, prompt, model = process.env.PUL
34
34
  ];
35
35
  }
36
36
 
37
- export function leaseConfig(outDir, { control = false } = {}) {
38
- const forbidden = control ? Object.fromEntries(['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].map((glob) => [`${outDir}/${glob}`, 'deny'])) : {};
37
+ // OpenCode matches permission patterns against paths relative to `--dir`, last matching rule
38
+ // wins, and creating a file is the `write` tool while changing one is `edit`: both need a rule.
39
+ // Verified against opencode 1.18.4: absolute patterns never match, so the lease is named by
40
+ // its directory relative to the project.
41
+ export const FORBIDDEN_GLOBS = ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'];
42
+ export function writeRules(relativeDir, { control = false, create = false } = {}) {
43
+ if (control || create) return { '*': 'allow', ...Object.fromEntries(FORBIDDEN_GLOBS.map((glob) => [glob, 'deny'])) };
44
+ const dir = String(relativeDir ?? '.').replace(/^\.\/+/, '').replace(/\/+$/, '');
45
+ return { '*': 'deny', [dir && dir !== '.' ? `${dir}/**` : '**']: 'allow' };
46
+ }
47
+
48
+ export function leaseConfig(outDir, { control = false, create = false, relativeDir = null } = {}) {
49
+ const rules = writeRules(relativeDir ?? outDir, { control, create });
50
+ // OpenCode gates its `write` tool behind the `edit` permission as well (denying edit leaves "no
51
+ // file-writing tool"), so CREATE allows both and the room restores existing files afterwards.
52
+ const editRules = rules;
39
53
  return {
40
54
  ...readonlyConfig,
41
55
  agent: {
42
56
  'pulse-readonly': {
43
57
  ...readonlyConfig.agent['pulse-readonly'],
44
58
  prompt: control
45
- ? 'Answer the user directly. You are in CONTROL of this project: create and edit files anywhere inside it except .git, .pulse and .env files. Do not run commands, browse the web, or launch subagents.'
46
- : 'Answer the user directly. Inspect project files when necessary. You may create or edit files only inside the creation lease directory named in the request; never elsewhere. Do not run commands, browse the web, or launch subagents.',
59
+ ? 'Answer the user directly. You are in CONTROL of this project: create and edit files anywhere inside it except .git, .pulse, .madre and .env files. Do not run commands, browse the web, or launch subagents.'
60
+ : create
61
+ ? 'Answer the user directly. You may create new files and folders anywhere in this project where they belong; do not modify or delete existing files. Do not run commands, browse the web, or launch subagents.'
62
+ : 'Answer the user directly. Inspect project files when necessary. You may create or edit files only inside the creation lease directory named in the request; never elsewhere. Do not run commands, browse the web, or launch subagents.',
47
63
  permission: {
48
64
  ...readonlyConfig.agent['pulse-readonly'].permission,
49
- edit: { '*': 'deny', [`${outDir}/**`]: 'allow', ...forbidden },
65
+ edit: editRules,
66
+ write: rules,
50
67
  },
51
68
  },
52
69
  },
@@ -54,7 +71,7 @@ export function leaseConfig(outDir, { control = false } = {}) {
54
71
  }
55
72
 
56
73
  export function openCodeConfig({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
57
- let config = lease ? leaseConfig(lease.outDir, { control: Boolean(lease.control) }) : readonlyConfig;
74
+ let config = lease ? leaseConfig(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), relativeDir: lease.relativeDir ?? null }) : readonlyConfig;
58
75
  if (scopes?.web) {
59
76
  config = {
60
77
  ...config,
@@ -108,14 +108,16 @@ export function resolveScopes(agentId, configured = {}) {
108
108
  const wanted = configured[scope] ?? (scope !== 'web');
109
109
  scopes[scope] = { capable, enabled: capable && wanted, wired: true };
110
110
  }
111
- // A standing lease: every turn of this agent may create files inside
112
- // .pulse/out/ without the human arming CREATE each time. Opt-in per agent.
113
- scopes.write.always = Boolean(configured.alwaysCreate) && scopes.write.enabled;
114
- // The ceiling: how far the composer may take this agent. Writing needs the
115
- // CLI to be able to write; CONTROL is capped at 2 until phase C wires it.
116
- const wanted = normalizeMode(configured.maxMode, scopes.write.enabled ? 2 : 1);
117
- scopes.maxMode = Math.min(wanted, scopes.write.capable ? 3 : 1);
118
- scopes.defaultMode = scopes.write.always && scopes.maxMode >= 2 ? 2 : 1;
111
+ // One ceiling per agent: MAX MODE. Writing follows from it: an agent capped at #1 never
112
+ // writes, one allowed to #2 or #3 does. A `write: false` from an older config reads as #1.
113
+ const capableMax = scopes.write.capable ? 3 : 1;
114
+ const legacyCap = configured.write === false ? 1 : 3;
115
+ scopes.maxMode = Math.min(normalizeMode(configured.maxMode, scopes.write.capable ? 2 : 1), legacyCap, capableMax);
116
+ scopes.write.enabled = scopes.write.capable && scopes.maxMode >= 2;
117
+ // One start per agent: DEFAULT MODE, #1 or #2, never above the ceiling. An older
118
+ // `alwaysCreate` reads as #2. `write.always` keeps its name for the room's code.
119
+ scopes.defaultMode = Math.min(normalizeMode(configured.defaultMode, configured.alwaysCreate ? 2 : 1), 2, Math.max(1, scopes.maxMode));
120
+ scopes.write.always = scopes.defaultMode >= 2 && scopes.write.enabled;
119
121
  return scopes;
120
122
  }
121
123
 
@@ -1,7 +1,8 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { access, mkdtemp, rm, unlink } from 'node:fs/promises';
2
+ import { access, mkdir, mkdtemp, readdir, rm, unlink } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join, resolve, sep } from 'node:path';
5
+ import { createHash } from 'node:crypto';
5
6
  import { promisify } from 'node:util';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
@@ -25,50 +26,143 @@ export const FORBIDDEN = [
25
26
  ];
26
27
  export const isForbidden = (path) => FORBIDDEN.some((pattern) => pattern.test(path));
27
28
 
28
- async function git(root, args, { env = {} } = {}) {
29
- const { stdout } = await execFileAsync('git', ['-c', 'core.quotepath=off', ...args], { cwd: root, env: { ...process.env, GIT_PAGER: 'cat', ...env }, maxBuffer: 16 * 1024 * 1024 });
30
- return stdout;
31
- }
32
-
33
29
  export async function isGitRepo(root) {
34
30
  return access(join(root, '.git')).then(() => true, () => false);
35
31
  }
36
32
 
33
+ // A project that is not a git repository still gets photographs: MADRE keeps a shadow
34
+ // repository of its own outside the project (one per project path) and runs git with the
35
+ // project as work tree. Nothing appears inside the project; UNDO and diffs work the same.
36
+ const shadows = new Map();
37
+ async function shadowFor(root) {
38
+ const canonical = resolve(root);
39
+ if (!shadows.has(canonical)) {
40
+ const dir = join(tmpdir(), 'madre-shadow', createHash('sha1').update(canonical).digest('hex').slice(0, 16));
41
+ await mkdir(dir, { recursive: true });
42
+ const isRepo = await access(join(dir, 'HEAD')).then(() => true, () => false);
43
+ if (!isRepo) await execFileAsync('git', ['init', '-q', '--bare', dir]);
44
+ shadows.set(canonical, dir);
45
+ }
46
+ return shadows.get(canonical);
47
+ }
48
+ async function gitArgs(root) {
49
+ if (await isGitRepo(root)) return [];
50
+ return ['--git-dir', await shadowFor(root), '--work-tree', root];
51
+ }
52
+
53
+ async function git(root, args, { env = {} } = {}) {
54
+ const { stdout } = await execFileAsync('git', ['-c', 'core.quotepath=off', ...(await gitArgs(root)), ...args], { cwd: root, env: { ...process.env, GIT_PAGER: 'cat', ...env }, maxBuffer: 16 * 1024 * 1024 });
55
+ return stdout;
56
+ }
57
+
37
58
  // A tree object of the working tree as it is right now: tracked and untracked
38
59
  // files, ignored ones left out. Built in a temporary index.
39
- export async function worktreeTree(root) {
60
+ export async function worktreeTree(root, { exclude = [] } = {}) {
40
61
  const dir = await mkdtemp(join(tmpdir(), 'madre-index-'));
41
62
  const index = join(dir, 'index');
42
63
  try {
43
64
  const env = { GIT_INDEX_FILE: index };
65
+ const extra = await gitArgs(root);
44
66
  await git(root, ['read-tree', '--empty'], { env });
45
- await git(root, ['add', '-A', '--', '.'], { env });
67
+ // Every file of the working tree that git would not ignore, listed against the empty
68
+ // index. Nested repositories come back as `dir/` entries and are left out: they are
69
+ // photographed on their own, and `git add` would refuse one without commits anyway.
70
+ const listed = await git(root, ['ls-files', '-z', '--others', '--exclude-standard', '--', '.'], { env });
71
+ const skip = new Set(exclude.map((path) => path.replace(/\/$/, '')));
72
+ // Lock folders (MADRE's own `*.lock/`) come and go between the listing and the indexing.
73
+ const files = listed.split('\0').filter((path) => path && !path.endsWith('/') && !/(^|\/)[^/]+\.lock\//.test(path) && ![...skip].some((dir) => path === dir || path.startsWith(`${dir}/`)));
74
+ if (files.length) {
75
+ // --remove: a file that vanished since the listing is dropped instead of aborting the photograph.
76
+ const index_ = (list) => new Promise((resolvePromise, reject) => {
77
+ const child = execFile('git', ['-c', 'core.quotepath=off', ...extra, 'update-index', '--add', '--remove', '-z', '--stdin'], { cwd: root, env: { ...process.env, GIT_PAGER: 'cat', ...env }, maxBuffer: 64 * 1024 * 1024 }, (error) => (error ? reject(error) : resolvePromise()));
78
+ child.stdin.end(list.join('\0') + '\0');
79
+ });
80
+ try { await index_(files); } catch {
81
+ // Something still raced us: photograph what exists right now.
82
+ const alive = [];
83
+ for (const path of files) if (await access(join(root, path)).then(() => true, () => false)) alive.push(path);
84
+ if (alive.length) await index_(alive);
85
+ }
86
+ }
46
87
  return (await git(root, ['write-tree'], { env })).trim();
47
88
  } finally {
48
89
  await rm(dir, { recursive: true, force: true });
49
90
  }
50
91
  }
51
92
 
52
- export async function createCheckpoint(root, { id, label = 'MADRE checkpoint' } = {}) {
53
- const tree = await worktreeTree(root);
93
+ // Repositories nested inside the project (a monorepo of sites, each with its own .git). The
94
+ // outer git sees them as a single entry and never notices what changes inside, so each one
95
+ // gets its own photograph. Ignored folders are skipped; depth is capped.
96
+ const NESTED_SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.pulse', '.madre', 'vendor']);
97
+ export async function nestedRepos(root, { maxDepth = 3 } = {}) {
98
+ const found = [];
99
+ const walk = async (dir, rel, depth) => {
100
+ let entries = [];
101
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
102
+ for (const entry of entries) {
103
+ if (!entry.isDirectory() || NESTED_SKIP.has(entry.name)) continue;
104
+ const path = join(dir, entry.name);
105
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
106
+ if (await isGitRepo(path)) { found.push(relPath); continue; } // a repo's own nested repos are its business
107
+ if (depth + 1 < maxDepth) await walk(path, relPath, depth + 1);
108
+ }
109
+ };
110
+ await walk(root, '', 0);
111
+ return found.sort();
112
+ }
113
+
114
+ async function checkpointOne(root, { id, label, exclude = [] }) {
115
+ const tree = await worktreeTree(root, { exclude });
54
116
  const head = (await git(root, ['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')).trim() || null;
55
117
  const commit = (await git(root, ['commit-tree', tree, ...(head ? ['-p', head] : []), '-m', `${label} ${id}`], { env: { GIT_AUTHOR_NAME: 'MADRE', GIT_AUTHOR_EMAIL: 'madre@localhost', GIT_COMMITTER_NAME: 'MADRE', GIT_COMMITTER_EMAIL: 'madre@localhost' } })).trim();
56
118
  await git(root, ['update-ref', `${CHECKPOINT_REF}/${id}`, commit]);
57
- return { id, commit, tree, head, createdAt: new Date().toISOString() };
119
+ return { commit, tree, head };
120
+ }
121
+
122
+ export async function createCheckpoint(root, { id, label = 'MADRE checkpoint' } = {}) {
123
+ const dirs = await nestedRepos(root);
124
+ const outer = await checkpointOne(root, { id, label, exclude: dirs });
125
+ const nested = [];
126
+ for (const dir of dirs) {
127
+ try { nested.push({ dir, ...(await checkpointOne(join(root, dir), { id, label })) }); } catch { /* a broken nested repo is left alone */ }
128
+ }
129
+ return { id, ...outer, nested, exclude: dirs, createdAt: new Date().toISOString() };
58
130
  }
59
131
 
60
132
  // What changed since the checkpoint: name-status per file and git's --stat.
61
- export async function diffCheckpoint(root, checkpoint) {
62
- const after = await worktreeTree(root);
63
- if (after === checkpoint.tree) return { afterTree: after, files: [], stat: '', forbidden: [] };
64
- const nameStatus = await git(root, ['diff', '--name-status', '-M', checkpoint.tree, after]);
133
+ async function diffOne(root, before, prefix = '', exclude = []) {
134
+ const after = await worktreeTree(root, { exclude });
135
+ if (after === before.tree) return { files: [], stat: '' };
136
+ const nameStatus = await git(root, ['diff', '--name-status', '-M', before.tree, after]);
65
137
  const files = nameStatus.split('\n').filter(Boolean).map((line) => {
66
138
  const [status, ...rest] = line.split('\t');
67
- const path = rest.at(-1);
68
- return { status: status[0], path, from: status[0] === 'R' ? rest[0] : undefined };
139
+ const path = prefix + rest.at(-1);
140
+ return { status: status[0], path, from: status[0] === 'R' ? prefix + rest[0] : undefined };
69
141
  });
70
- const stat = (await git(root, ['diff', '--stat=100', checkpoint.tree, after])).trim();
71
- return { afterTree: after, files, stat, forbidden: files.filter((file) => isForbidden(file.path)).map((file) => file.path) };
142
+ const stat = (await git(root, ['diff', '--stat=100', before.tree, after])).trim();
143
+ return { files, stat: prefix && stat ? stat.split('\n').map((line) => (line.startsWith(' ') ? ` ${prefix}${line.trimStart()}` : line)).join('\n') : stat };
144
+ }
145
+
146
+ // What changed since the checkpoint, in the project and in every nested repository, paths
147
+ // relative to the project. Forbidden zones are judged on the path inside each repository too.
148
+ export async function diffCheckpoint(root, checkpoint) {
149
+ const outer = await diffOne(root, checkpoint, '', checkpoint.exclude ?? []);
150
+ const parts = [outer];
151
+ for (const part of checkpoint.nested ?? []) {
152
+ try { parts.push(await diffOne(join(root, part.dir), part, `${part.dir}/`)); } catch { /* left alone */ }
153
+ }
154
+ const files = parts.flatMap((part) => part.files);
155
+ const stat = parts.map((part) => part.stat).filter(Boolean).join('\n');
156
+ const forbiddenIn = (path) => isForbidden(path) || (checkpoint.nested ?? []).some((part) => path.startsWith(`${part.dir}/`) && isForbidden(path.slice(part.dir.length + 1)));
157
+ return { afterTree: outer.afterTree ?? null, files, stat, forbidden: files.filter((file) => forbiddenIn(file.path)).map((file) => file.path) };
158
+ }
159
+
160
+ // Which repository a project-relative path belongs to, and the path inside it.
161
+ function repoFor(root, checkpoint, path) {
162
+ for (const part of checkpoint.nested ?? []) {
163
+ if (path.startsWith(`${part.dir}/`)) return { dir: join(root, part.dir), commit: part.commit, inner: path.slice(part.dir.length + 1) };
164
+ }
165
+ return { dir: root, commit: checkpoint.commit, inner: path };
72
166
  }
73
167
 
74
168
  // Put the working tree back to the checkpoint: files added since are removed,
@@ -83,18 +177,20 @@ export async function restoreCheckpoint(root, checkpoint, { paths = null } = {})
83
177
  for (const file of chosen) {
84
178
  const absolute = resolve(root, file.path);
85
179
  if (!absolute.startsWith(canonicalRoot + sep)) continue;
180
+ const repo = repoFor(root, checkpoint, file.path);
86
181
  if (file.status === 'A') {
87
182
  await unlink(absolute).catch(() => {});
88
183
  removed.push(file.path);
89
184
  } else {
90
185
  if (file.status === 'R' && file.from) {
91
186
  await unlink(absolute).catch(() => {});
92
- await git(root, ['restore', '--source', checkpoint.commit, '--worktree', '--', file.from]);
187
+ const origin = repoFor(root, checkpoint, file.from);
188
+ await git(origin.dir, ['restore', '--source', origin.commit, '--worktree', '--', origin.inner]);
93
189
  restored.push(file.from);
94
190
  removed.push(file.path);
95
191
  continue;
96
192
  }
97
- await git(root, ['restore', '--source', checkpoint.commit, '--worktree', '--', file.path]);
193
+ await git(repo.dir, ['restore', '--source', repo.commit, '--worktree', '--', repo.inner]);
98
194
  restored.push(file.path);
99
195
  }
100
196
  }
@@ -103,4 +199,5 @@ export async function restoreCheckpoint(root, checkpoint, { paths = null } = {})
103
199
 
104
200
  export async function dropCheckpoint(root, checkpoint) {
105
201
  await git(root, ['update-ref', '-d', `${CHECKPOINT_REF}/${checkpoint.id}`]).catch(() => {});
202
+ for (const part of checkpoint.nested ?? []) await git(join(root, part.dir), ['update-ref', '-d', `${CHECKPOINT_REF}/${checkpoint.id}`]).catch(() => {});
106
203
  }
@@ -21,6 +21,7 @@ export const DELEGATION_HELP = (self, others, maxSteps) => [
21
21
  `@${others[0] ?? 'codex'}: <question for that agent>`,
22
22
  `@${self}: <what you will do with their answers, optional closing turn for you>`,
23
23
  '```',
24
+ 'A step may name the mode it needs, "@codex #2: create the page" or "@claude #3: fix the router"; MADRE caps it at your own mode and at that agent\'s MAX MODE. Without a number a step inherits the plan\'s mode.',
24
25
  `MADRE runs the steps in order (at most ${maxSteps}), shows every answer in the room, then hands you the closing turn if you asked for one.`,
25
26
  'The plan block must be the very last thing in your reply: nothing after it, not even a closing sentence. Say everything else before it.',
26
27
  'Address each agent once. Do not delegate what you can answer yourself.',
@@ -45,10 +46,12 @@ export function parseDirectives(text, { self, available = [], maxSteps = 4 } = {
45
46
  for (const raw of block[1].split('\n')) {
46
47
  const line = raw.trim();
47
48
  if (!line || line.startsWith('#')) continue;
48
- const match = line.match(/^[-*]?\s*@([a-z0-9_-]+)\s*[::]\s*(.+)$/i);
49
- if (!match) { ignored.push({ line, reason: 'not a step (expected "@agent: text")' }); continue; }
49
+ // "@agent: text" or "@agent #2: text": the mode is a request, capped by the plan's ceiling and the agent's MAX MODE.
50
+ const match = line.match(/^[-*]?\s*@([a-z0-9_-]+)\s*(?:#([0-3]))?\s*[::]\s*(.+)$/i);
51
+ if (!match) { ignored.push({ line, reason: 'not a step (expected "@agent: text" or "@agent #2: text")' }); continue; }
50
52
  const agent = match[1].toLowerCase();
51
- const instruction = match[2].trim();
53
+ const instruction = match[3].trim();
54
+ const mode = match[2] === undefined ? null : Number(match[2]);
52
55
  if (agent === self) {
53
56
  if (closing) { ignored.push({ line, reason: 'only one closing step for the orchestrator' }); continue; }
54
57
  closing = instruction;
@@ -58,7 +61,7 @@ export function parseDirectives(text, { self, available = [], maxSteps = 4 } = {
58
61
  if (seen.has(agent)) { ignored.push({ line, reason: `@${agent} already has a step` }); continue; }
59
62
  if (steps.length >= maxSteps) { ignored.push({ line, reason: `plan is capped at ${maxSteps} steps` }); continue; }
60
63
  seen.add(agent);
61
- steps.push({ agent, text: instruction });
64
+ steps.push({ agent, text: instruction, ...(mode === null ? {} : { mode }) });
62
65
  }
63
66
  }
64
67
  return { steps, closing, ignored };
package/src/lease.mjs CHANGED
@@ -63,19 +63,24 @@ export function diffSnapshots(before, after, { relativeDir }) {
63
63
  return artifacts.sort((a, b) => a.path.localeCompare(b.path));
64
64
  }
65
65
 
66
- export function leaseInstructions({ outDir, agentId, scopes = { write: true, imageGen: agentId === 'codex' }, capable = null, imageStudio = null, control = false }) {
66
+ export function leaseInstructions({ outDir, agentId, scopes = { write: true, imageGen: agentId === 'codex' }, capable = null, imageStudio = null, control = false, create = false, scratchDir = null }) {
67
67
  const canImage = Boolean(scopes.imageGen);
68
68
  const couldImage = capable ? Boolean(capable.imageGen?.capable) : canImage;
69
+ const scratch = scratchDir ? `${outDir}/${scratchDir}` : null;
69
70
  return [
70
71
  control
71
72
  ? `CONTROL (#3): the human put you in command of this project at ${outDir}. You may read, create and modify its files without asking, one change at a time, minimal and reversible. MADRE took a checkpoint before this turn; everything you change is listed to the human afterwards and can be undone in one click.`
72
- : `CREATION LEASE: the human allows you to create files for this request, only inside ${outDir}.`,
73
+ : create
74
+ ? `CREATE (#2): the human allows you to create new files and folders anywhere in this project, at ${outDir}, where they belong by the project's own conventions (a page next to the other pages, a component with the components, a document with the documents). Create folders when the structure calls for it.${scratch ? ` If something has no natural place, put it in the scratch folder ${scratch}.` : ''}`
75
+ : `CREATION LEASE: the human allows you to create files for this request, only inside ${outDir}.`,
73
76
  control
74
- ? 'Never touch .git, .pulse, .env files or credentials: writes there are denied and reverted. Do not run destructive commands. Do not delegate this power: other agents you involve work read-only.'
75
- : 'Write every file you produce there (images, code, documents); paths elsewhere are denied.',
76
- control ? 'End with a short list of the files you changed and why.' : 'Reading the project stays allowed. Do not modify project files.',
77
- imageStudio ? `You can generate images with the MCP tool ${imageStudio.tool} (server ${imageStudio.name}): pass a detailed prompt and a file_name; it saves the PNG into the lease directory and returns the path.`
78
- : canImage ? 'You can generate images; save them into the lease directory with a descriptive file name.'
77
+ ? 'Never touch .git, .pulse, .madre, .env files or credentials: writes there are denied and reverted. Do not run destructive commands. Do not delegate this power: other agents you involve work read-only unless a step of yours names a mode.'
78
+ : create
79
+ ? 'Do not modify, overwrite, rename or delete files that already exist: MADRE restores them after your turn and tells the human. If a change to an existing file is truly needed, say so and stop; the human can grant #3 CONTROL. Never touch .git, .pulse, .madre, .env files or credentials.'
80
+ : 'Write every file you produce there (images, code, documents); paths elsewhere are denied.',
81
+ control ? 'End with a short list of the files you changed and why.' : create ? 'End with a short list of the files you created, with their paths, and why there.' : 'Reading the project stays allowed. Do not modify project files.',
82
+ imageStudio ? `You can generate images with the MCP tool ${imageStudio.tool} (server ${imageStudio.name}): pass a detailed prompt and a file_name; it saves the PNG${scratch ? ` into ${scratch}` : ' into the lease directory'} and returns the path.`
83
+ : canImage ? `You can generate images; save them${create ? ' where images live in this project, or in the scratch folder,' : ' into the lease directory'} with a descriptive file name.`
79
84
  : couldImage ? 'Image generation is switched off for this request; if asked for an image, say so and do not attempt it.'
80
85
  : 'You cannot generate images from this CLI; if asked for one, say so plainly instead of attempting it.',
81
86
  'List the files you created (or "none") before any plan block; nothing may follow a plan block.',
@@ -20,32 +20,44 @@ export class ControlDesk {
20
20
 
21
21
  // Takes the checkpoint and seats the agent. Returns the run, the lease that
22
22
  // makes the whole project writable, and what the room should announce.
23
- async begin({ agent, messageId, enabledScopes }) {
24
- const checkpoint = await createCheckpoint(this.#projectRoot, { id: `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${messageId.slice(0, 8)}`, label: `MADRE control @${agent.id}` });
23
+ // mode 3 (CONTROL) seats one holder for the whole project; mode 2 (CREATE) takes the same
24
+ // photograph without a seat: the project is writable for new files, and settle() puts back
25
+ // whatever existed before. Several #2 turns may run at once.
26
+ async begin({ agent, messageId, enabledScopes, mode = 3 }) {
27
+ const checkpoint = await createCheckpoint(this.#projectRoot, { id: `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${messageId.slice(0, 8)}`, label: `MADRE ${mode === 3 ? 'control' : 'create'} @${agent.id}` });
25
28
  checkpoint.agent = agent.id;
26
29
  this.#checkpoints.set(checkpoint.id, checkpoint);
27
30
  // Prevention first: .env files and MADRE's folders are read-only for the length of the turn.
28
31
  const guard = await guardForbidden(this.#projectRoot);
29
- const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard };
30
- this.#holder = run;
31
- const lease = { leaseId: checkpoint.id, outDir: this.#projectRoot, relativeDir: '.', scopes: { ...enabledScopes, write: true }, control: true, checkpoint };
32
+ const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard, mode };
33
+ if (mode === 3) this.#holder = run;
34
+ const lease = { leaseId: checkpoint.id, outDir: this.#projectRoot, relativeDir: '.', scopes: { ...enabledScopes, write: true }, control: mode === 3, create: mode === 2, checkpoint };
35
+ if (mode !== 3) return { run, lease, announcement: null };
32
36
  const guarded = guard.locked.length ? ` ${guard.locked.length} forbidden path${guard.locked.length === 1 ? '' : 's'} locked read-only for the turn (${guard.locked.slice(0, 4).join(', ')}${guard.locked.length > 4 ? ', …' : ''}).` : '';
33
37
  const announcement = { checkpointId: checkpoint.id, commit: checkpoint.commit, head: checkpoint.head, agent: agent.id, messageId, guarded: guard.locked, message: `@${agent.id} holds CONTROL of the project. Checkpoint ${checkpoint.commit.slice(0, 7)} taken; UNDO will be one click.${guarded}` };
34
38
  return { run, lease, announcement };
35
39
  }
36
40
 
37
- // What really changed, with forbidden zones already restored.
41
+ // What really changed, with forbidden zones already restored. In a #2 turn every change to
42
+ // a file that existed before (modified, deleted, renamed) is restored too: CREATE adds, only.
38
43
  async settle(run) {
39
44
  const diff = await diffCheckpoint(this.#projectRoot, run.checkpoint);
45
+ const additive = run.mode === 2;
46
+ const toRevert = new Set(diff.forbidden);
47
+ if (additive) for (const file of diff.files) if (file.status !== 'A') toRevert.add(file.path);
40
48
  let reverted = [];
41
- if (diff.forbidden.length) {
42
- const restored = await restoreCheckpoint(this.#projectRoot, run.checkpoint, { paths: diff.forbidden });
49
+ if (toRevert.size) {
50
+ const restored = await restoreCheckpoint(this.#projectRoot, run.checkpoint, { paths: [...toRevert] });
43
51
  reverted = [...restored.restored, ...restored.removed];
44
52
  }
45
- const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, files: diff.files.filter((file) => !diff.forbidden.includes(file.path)), stat: diff.stat, forbiddenReverted: reverted };
53
+ const kept = diff.files.filter((file) => !toRevert.has(file.path));
54
+ const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, forbiddenReverted: diff.forbidden.length ? reverted.filter((path) => diff.forbidden.includes(path)) : [], existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [] };
46
55
  const count = changes.files.length;
47
- const note = reverted.length ? ` ${reverted.length} write(s) into forbidden zones were reverted.` : '';
48
- changes.message = `${count ? `@${run.agent} changed ${count} file(s) in the project.` : `@${run.agent} changed nothing in the project.`}${note}`;
56
+ const notes = [
57
+ changes.forbiddenReverted.length ? `${changes.forbiddenReverted.length} write(s) into forbidden zones were reverted.` : null,
58
+ changes.existingReverted.length ? `${changes.existingReverted.length} change(s) to existing files were put back: CREATE only adds.` : null,
59
+ ].filter(Boolean).join(' ');
60
+ changes.message = `${count ? `@${run.agent} ${additive ? 'created' : 'changed'} ${count} file(s) in the project.` : `@${run.agent} ${additive ? 'created' : 'changed'} nothing in the project.`}${notes ? ` ${notes}` : ''}`;
49
61
  return changes;
50
62
  }
51
63
 
@@ -26,7 +26,7 @@ export function buildPrompt({
26
26
  'You are answering inside a MADRE project room shared by a human and several AI agents.',
27
27
  `You are @${agent.id}.`,
28
28
  `Permission mode for this turn: #${mode} ${MODES[mode]?.label ?? ''}.${mode === 0 ? ' This exchange is off the record: it is not written to the room transcript, no other agent will see it, and nothing you say here can be referred to later. Do not coordinate with other agents.' : mode === 2 ? ' You may create files, only inside the lease directory described below.' : ' Read-only: you may read the project and coordinate, not create or modify files.'}`,
29
- lease ? 'Inspect the project as needed; the only writable place is the creation lease directory below.' : `Inspect the project only as needed. Operate read-only and do not modify files.${scopes?.web ? '' : ' Do not access the web.'}`,
29
+ lease ? (lease.control ? 'Inspect the project as needed; you hold it for this turn, as described below.' : lease.create ? 'Inspect the project as needed; you may add new files to it as described below, never change existing ones.' : 'Inspect the project as needed; the only writable place is the creation lease directory below.') : `Inspect the project only as needed. Operate read-only and do not modify files.${scopes?.web ? '' : ' Do not access the web.'}`,
30
30
  'Answer directly and concisely. Clearly distinguish facts from inference.',
31
31
  madreModel && agent.id !== 'madre'
32
32
  ? `@madre is in the room${madreModel.startsWith('madre-') ? ` running this project's own trained model (${madreModel})` : ` (local, ${madreModel})`}: it answers from the whole archive with citations and costs no tokens. For "what did we decide", "did we ever discuss" or "where did we leave" questions, ask it or delegate the recall step to it instead of searching yourself.`
@@ -50,7 +50,7 @@ export function buildPrompt({
50
50
  : null,
51
51
  mayDelegate ? DELEGATION_HELP(agent.id, others, maxPlanSteps) : null,
52
52
  mayDelegate ? `Abilities right now (route each step to an agent that can do it):\n${[agent.id, ...others].map((id) => abilityLine(id, scopesFor(id))).join('\n')}` : null,
53
- lease ? leaseInstructions({ outDir: lease.outDir, agentId: agent.id, scopes: lease.scopes, capable: scopesFor(agent.id), imageStudio, control: Boolean(lease.control) }) : null,
53
+ lease ? leaseInstructions({ outDir: lease.outDir, agentId: agent.id, scopes: lease.scopes, capable: scopesFor(agent.id), imageStudio, control: Boolean(lease.control), create: Boolean(lease.create), scratchDir: lease.scratchDir ?? null }) : null,
54
54
  !lease?.control && controlHolder && controlHolder !== agent.id ? `Heads-up: @${controlHolder} currently holds CONTROL and may be changing project files while you work; cite the state you actually read.` : null,
55
55
  escalation ? `The human was asked to allow file creation for this step and ${escalation === 'timeout' ? 'did not answer in time' : escalation === 'stopped' ? 'stopped the plan' : 'declined'}. Answer read-only: say plainly what you would have created and what it would contain, without creating it.` : null,
56
56
  scopes?.web ? 'WEB ACCESS: the human enabled web search and fetch for you; use them when the question needs current or external information, and cite the sources you used.' : null,
package/src/room.mjs CHANGED
@@ -21,6 +21,9 @@ import { parseDirectives } from './directives.mjs';
21
21
  import { isValidModelName } from './models.mjs';
22
22
  import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
23
23
  import { createLease, diffSnapshots, snapshot } from './lease.mjs';
24
+ import { stat as statFile } from 'node:fs/promises';
25
+ import { basename as baseName, join as joinPath } from 'node:path';
26
+ import { contentTypeFor } from './files.mjs';
24
27
  import { imageStudioFor } from './image-studio.mjs';
25
28
  import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
26
29
  import { resolveReferences } from './files.mjs';
@@ -193,8 +196,7 @@ export class Room {
193
196
  }
194
197
  const scopes = this.scopesFor(step.agent);
195
198
  const enabled = Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired]));
196
- const lease = await createLease({ projectRoot: this.#projectRoot, leaseId: randomUUID() });
197
- lease.messageId = parentMessageId;
199
+ const lease = await this.#projectLease({ leaseId: randomUUID(), messageId: parentMessageId });
198
200
  lease.scopes = decision.decision === 'plan' ? Object.fromEntries(SCOPES.map((scope) => [scope, true])) : enabled;
199
201
  await this.#emit('lease.granted', {
200
202
  escalated: decision.decision,
@@ -202,6 +204,7 @@ export class Room {
202
204
  messageId: parentMessageId,
203
205
  agent: step.agent,
204
206
  outDir: lease.relativeDir,
207
+ scratchDir: lease.scratchDir,
205
208
  scopes: SCOPES.filter((scope) => enabled[scope]),
206
209
  unavailable: SCOPES.filter((scope) => !scopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
207
210
  planId,
@@ -574,10 +577,9 @@ export class Room {
574
577
  message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${unavailable.length ? `; it cannot ${unavailable.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
575
578
  });
576
579
  } else {
577
- lease = await createLease({ projectRoot: this.#projectRoot, leaseId: randomUUID() });
578
- lease.messageId = messageId;
580
+ lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
579
581
  // CREATE authorizes the plan to use each delegate's enabled creation
580
- // scopes; a standing write lease authorizes files only.
582
+ // scopes; a default-#2 lease authorizes files only.
581
583
  lease.scopeCeiling = { write: true, imageGen: !standing, web: true };
582
584
  lease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, enabled.includes(scope) && (!standing || scope === 'write')]));
583
585
  await this.#emit('lease.granted', {
@@ -586,6 +588,7 @@ export class Room {
586
588
  messageId,
587
589
  agent: parsed.target,
588
590
  outDir: lease.relativeDir,
591
+ scratchDir: lease.scratchDir,
589
592
  grantedBy: 'you',
590
593
  scopes: standing ? ['write'] : enabled,
591
594
  unavailable,
@@ -609,6 +612,25 @@ export class Room {
609
612
  await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ashCode: ashActive, mode: ghost ? 0 : control ? 3 : (lease ? 2 : 1) }));
610
613
  }
611
614
 
615
+ // A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
616
+ // under .pulse/out/ for what has no natural place. Each turn under it takes a checkpoint
617
+ // and puts back whatever existed before, so CREATE only ever adds.
618
+ async #projectLease({ leaseId, messageId }) {
619
+ const scratch = await createLease({ projectRoot: this.#projectRoot, leaseId });
620
+ return { leaseId, messageId, outDir: this.#projectRoot, relativeDir: '.', scratchDir: scratch.relativeDir, create: true };
621
+ }
622
+
623
+ // Artifacts from a #2 turn: the files the checkpoint saw appear, as the room shows them.
624
+ async #artifactsFrom(files) {
625
+ const out = [];
626
+ for (const file of files) {
627
+ if (file.status !== 'A') continue;
628
+ const info = await statFile(joinPath(this.#projectRoot, file.path)).catch(() => null);
629
+ out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType: contentTypeFor(file.path), status: 'created' });
630
+ }
631
+ return out.sort((a, b) => a.path.localeCompare(b.path));
632
+ }
633
+
612
634
  // Agents that can receive a delegated step from `self`.
613
635
  delegatesFor(self) {
614
636
  return this.#agents.filter((agent) => agent.ready && agent.id !== self).map((agent) => agent.id);
@@ -714,7 +736,7 @@ export class Room {
714
736
  // The orchestrator's turn is over before its plan starts, so it is never
715
737
  // counted as in flight while the others work.
716
738
  if (outcome?.directives?.steps.length) {
717
- await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ashCode });
739
+ await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ashCode, mode });
718
740
  }
719
741
  return outcome?.responseMessageId ?? null;
720
742
  }
@@ -745,13 +767,19 @@ export class Room {
745
767
  const turnScopes = { web: enabled.web, imageGen: enabled.imageGen };
746
768
  let lease = null;
747
769
  let controlRun = null;
748
- if (mode === 3 && requester === 'you' && depth === 0) {
770
+ let createRun = null;
771
+ if (mode === 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= 3 && enabled.write) {
749
772
  // CONTROL: the project itself is the writable root, and a checkpoint
750
- // taken now makes every change of this turn reversible.
751
- const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled });
752
- controlRun = seat.run;
753
- lease = seat.lease;
754
- await this.#emit('control.started', seat.announcement);
773
+ // taken now makes every change of this turn reversible. A plan step gets
774
+ // it when the orchestrator, itself in #3, named #3 for that step.
775
+ if (this.#controlDesk.holder) {
776
+ await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
777
+ } else {
778
+ const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled });
779
+ controlRun = seat.run;
780
+ lease = seat.lease;
781
+ await this.#emit('control.started', seat.announcement);
782
+ }
755
783
  } else if (sharedLease) {
756
784
  lease = enabled.write ? {
757
785
  ...sharedLease,
@@ -760,8 +788,7 @@ export class Room {
760
788
  } else if (agentScopes.write.always && enabled.write && requester !== 'you' && requester !== 'mother') {
761
789
  // Standing lease for a delegate: the human opted this agent into
762
790
  // creating files on every turn, so a plan step gets its own directory.
763
- lease = await createLease({ projectRoot: this.#projectRoot, leaseId: randomUUID() });
764
- lease.messageId = messageId;
791
+ lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
765
792
  lease.scopeCeiling = { write: true, imageGen: false, web: true };
766
793
  lease.scopes = { ...enabled, imageGen: false };
767
794
  await this.#emit('lease.granted', {
@@ -770,11 +797,18 @@ export class Room {
770
797
  messageId,
771
798
  agent: agent.id,
772
799
  outDir: lease.relativeDir,
800
+ scratchDir: lease.scratchDir,
773
801
  scopes: ['write'],
774
802
  unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
775
803
  planId,
776
804
  });
777
805
  }
806
+ if (lease?.create && !controlRun) {
807
+ // CREATE: photograph the project now; after the turn only what appeared stays.
808
+ const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2 });
809
+ createRun = seat.run;
810
+ lease = { ...lease, checkpoint: seat.run.checkpoint };
811
+ }
778
812
  turnScopes.imageGen = Boolean(lease?.scopes?.imageGen);
779
813
  // The step asks for files but nobody granted a lease: say so now, with a
780
814
  // way out, instead of letting the agent's refusal be the only signal.
@@ -793,14 +827,14 @@ export class Room {
793
827
  const native = Boolean(CAPABILITIES[agent.id]?.imageGen);
794
828
  const studio = imageModuleState();
795
829
  const imageStudio = lease?.scopes?.imageGen && enabled.imageGen && !native && studio.enabled
796
- ? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.outDir })
830
+ ? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : lease.outDir })
797
831
  : null;
798
832
  // The turn's effective mode: #0 for ghosts, #3 in control, #2 only while it holds a lease.
799
833
  const turnMode = mode === 0 ? 0 : controlRun ? 3 : lease ? 2 : 1;
800
834
  try {
801
835
  const invoke = this.#invokers[agent.adapter];
802
836
  if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
803
- const before = lease && !lease.control ? await snapshot(lease.outDir) : null;
837
+ const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
804
838
  const responseMessageId = randomUUID();
805
839
  const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
806
840
  const others = this.delegatesFor(agent.id);
@@ -828,7 +862,9 @@ export class Room {
828
862
  // the archivist or any other agent can see it. The reply says how many, not which.
829
863
  const guarded = this.#privacy?.redact(result.text ?? '') ?? { text: result.text, hits: 0 };
830
864
  if (guarded.hits) result.text = guarded.text;
831
- const artifacts = lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
865
+ // CREATE: what appeared stays and is shown; what existed before is put back and said.
866
+ const createChanges = createRun ? await this.#controlDesk.settle(createRun) : null;
867
+ const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
832
868
  // CONTROL: what really changed in the project, forbidden zones reverted on the spot.
833
869
  const controlChanges = controlRun ? await this.#controlDesk.settle(controlRun) : null;
834
870
  const directives = mayDelegate
@@ -866,6 +902,9 @@ export class Room {
866
902
  if (artifacts.length) {
867
903
  await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
868
904
  }
905
+ if (createChanges && (createChanges.existingReverted.length || createChanges.forbiddenReverted.length)) {
906
+ await this.#emit('create.reverted', { checkpointId: createChanges.checkpointId, agent: agent.id, messageId, responseMessageId, existing: createChanges.existingReverted, forbidden: createChanges.forbiddenReverted, message: createChanges.message });
907
+ }
869
908
  // What the agent saved through memory_note during this turn, for the bubble's hint.
870
909
  if (this.#memory && turnMode !== 0) {
871
910
  const noted = this.#memory.notesSince(notesBefore, { agent: agent.id });
@@ -884,24 +923,32 @@ export class Room {
884
923
  } finally {
885
924
  // Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.
886
925
  await this.#controlDesk.release(controlRun);
926
+ await this.#controlDesk.release(createRun);
887
927
  }
888
928
  }
889
929
 
890
- async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false }) {
930
+ async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false, mode = 1 }) {
891
931
  const planId = randomUUID();
892
- // The plan runs at the human's mode: #2 only while a lease exists, and a
893
- // step's agent gets #2 only if its own scopes allow writing.
894
- const stepMode = (agentId) => (lease && this.scopesFor(agentId).write.enabled ? 2 : 1);
932
+ // The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
933
+ // lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
934
+ // of what it asked, the ceiling and its own MAX MODE. Without a number it inherits the plan.
935
+ const ceiling = mode >= 3 ? 3 : lease ? 2 : 1;
936
+ const stepMode = (step) => {
937
+ const scopes = this.scopesFor(step.agent);
938
+ const asked = normalizeMode(step.mode, lease ? 2 : 1);
939
+ const granted = Math.min(asked, ceiling, scopes.maxMode);
940
+ return granted >= 2 && !scopes.write.enabled ? 1 : granted;
941
+ };
895
942
  const plan = { planId, orchestrator, steps: directives.steps, closing: directives.closing, step: 0, stopped: null, startedAt: Date.now() };
896
943
  this.#plans.set(planId, plan);
897
944
  await this.#emit('plan.created', {
898
945
  planId,
899
946
  orchestrator,
900
947
  parentMessageId,
901
- steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step.agent) })),
948
+ steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step) })),
902
949
  closing: directives.closing,
903
950
  ignored: directives.ignored,
904
- mode: lease ? 2 : 1,
951
+ mode: ceiling,
905
952
  leaseId: lease?.leaseId ?? null,
906
953
  });
907
954
  try {
@@ -917,7 +964,16 @@ export class Room {
917
964
  let stepLease = lease;
918
965
  let escalation = null;
919
966
  const stepScopes = this.scopesFor(step.agent);
920
- if (!lease && !stepScopes.write.always && looksLikeCreation(step.text) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
967
+ const wanted = stepMode(step);
968
+ // Under a #3 ceiling the orchestrator's word is enough: a #2 step gets its project lease,
969
+ // a #3 step gets CONTROL for its turn. Under #1 a creation step still asks the human.
970
+ if (ceiling === 3 && !stepLease && wanted === 2) {
971
+ stepLease = await this.#projectLease({ leaseId: randomUUID(), messageId });
972
+ stepLease.scopeCeiling = { write: true, imageGen: true, web: true };
973
+ stepLease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, stepScopes[scope].enabled && stepScopes[scope].wired]));
974
+ await this.#emit('lease.granted', { delegated: true, leaseId: stepLease.leaseId, messageId, agent: step.agent, outDir: '.', scratchDir: stepLease.scratchDir, grantedBy: orchestrator, scopes: SCOPES.filter((scope) => stepLease.scopes[scope]), unavailable: [], planId });
975
+ }
976
+ if (ceiling < 3 && !lease && !stepScopes.write.always && (wanted >= 2 || looksLikeCreation(step.text)) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
921
977
  const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + (directives.closing ? 1 : 0), orchestrator, parentMessageId, mode: 2 });
922
978
  if (outcome.scope === 'plan') lease = outcome.lease;
923
979
  if (outcome.scope) stepLease = outcome.lease; else escalation = outcome.reason;
@@ -933,12 +989,12 @@ export class Room {
933
989
  ashCode: ashCode ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
934
990
  status: 'delegated',
935
991
  planId,
936
- mode: stepLease && stepScopes.write.enabled ? 2 : 1,
992
+ mode: wanted === 3 ? 3 : stepLease && stepScopes.write.enabled ? 2 : 1,
937
993
  escalation: escalation ?? undefined,
938
994
  step: index + 1,
939
995
  totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
940
996
  });
941
- await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: stepLease, ashCode, mode: stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
997
+ await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted === 3 ? null : stepLease, ashCode, mode: wanted === 3 ? 3 : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
942
998
  }
943
999
  if (!plan.stopped && directives.closing) {
944
1000
  plan.step = directives.steps.length + 1;
package/src/server.mjs CHANGED
@@ -323,7 +323,9 @@ export async function createPulseServer({
323
323
  if (!agents.some((agent) => agent.id === agentId) || !scopes || typeof scopes !== 'object') continue;
324
324
  config.scopes[agentId] = { ...(current[agentId] ?? {}) };
325
325
  for (const scope of ['write', 'imageGen', 'web', 'alwaysCreate']) if (typeof scopes[scope] === 'boolean') config.scopes[agentId][scope] = scopes[scope];
326
- if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 3) config.scopes[agentId].maxMode = Number(scopes.maxMode);
326
+ // MAX MODE and DEFAULT MODE are the two controls; setting them retires the older flags they replace.
327
+ if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 3) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
328
+ if (Number.isInteger(Number(scopes.defaultMode)) && Number(scopes.defaultMode) >= 1 && Number(scopes.defaultMode) <= 2) { config.scopes[agentId].defaultMode = Number(scopes.defaultMode); delete config.scopes[agentId].alwaysCreate; }
327
329
  }
328
330
  room.setScopes(config.scopes);
329
331
  }