@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 +12 -0
- package/README.md +7 -5
- package/docs/INTERNALS.md +7 -1
- package/package.json +1 -1
- package/public/app.js +57 -37
- package/public/troubleshooting.js +4 -4
- package/src/adapters/claude.mjs +4 -1
- package/src/adapters/gemini.mjs +8 -5
- package/src/adapters/opencode.mjs +23 -6
- package/src/capabilities.mjs +10 -8
- package/src/checkpoint.mjs +118 -21
- package/src/directives.mjs +7 -4
- package/src/lease.mjs +12 -7
- package/src/room/control.mjs +23 -11
- package/src/room/prompt.mjs +2 -2
- package/src/room.mjs +82 -26
- package/src/server.mjs +3 -1
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** |
|
|
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.**
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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: '
|
|
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: '
|
|
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}
|
|
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
|
-
|
|
1598
|
-
node.append(
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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
|
-
?
|
|
2355
|
-
: '
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
315
|
-
fixes: same(['# once: composer → CREATE (lock) → send, or /create <request>', '# always: ⚙ CONNECTIONS → agent card →
|
|
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',
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -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.
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
:
|
|
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:
|
|
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,
|
package/src/capabilities.mjs
CHANGED
|
@@ -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
|
-
//
|
|
112
|
-
//
|
|
113
|
-
scopes.write.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
package/src/checkpoint.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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 {
|
|
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
|
-
|
|
62
|
-
const after = await worktreeTree(root);
|
|
63
|
-
if (after ===
|
|
64
|
-
const nameStatus = await git(root, ['diff', '--name-status', '-M',
|
|
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',
|
|
71
|
-
return {
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/src/directives.mjs
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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[
|
|
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
|
-
:
|
|
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
|
-
:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.',
|
package/src/room/control.mjs
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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:
|
|
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 (
|
|
42
|
-
const restored = await restoreCheckpoint(this.#projectRoot, run.checkpoint, { paths:
|
|
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
|
|
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
|
|
48
|
-
|
|
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
|
|
package/src/room/prompt.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
893
|
-
// step
|
|
894
|
-
|
|
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
|
|
948
|
+
steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step) })),
|
|
902
949
|
closing: directives.closing,
|
|
903
950
|
ignored: directives.ignored,
|
|
904
|
-
mode:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|