@jossuealcala/madre 0.5.0 → 0.5.1
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/package.json +1 -1
- package/public/app.js +14 -6
- package/public/es.js +4 -0
- package/public/styles.css +4 -0
- package/public/syntax.js +56 -2
- package/src/room/control.mjs +10 -3
- package/src/room.mjs +3 -0
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.5.1 · 2026-09-28
|
|
8
|
+
|
|
9
|
+
Cuatro cosas que salieron del QA de 0.5.0 el mismo día que se publicó. Ninguna cambia modos, módulos, agentes ni a dónde sale nada: son arreglos.
|
|
10
|
+
|
|
11
|
+
### Los cuatro reportes de 0.5.0
|
|
12
|
+
|
|
13
|
+
- **`/module` nunca llegaba a su tarjeta de instalación** (#5). MADRE le dice al agente en qué carpeta escribir el borrador —`.pulse/out/<turno>/`— y después revertía esa escritura por caer en zona prohibida: `.pulse/` lo es. La sala nombraba un sitio y castigaba por usarlo. Aquí nunca se vio porque **este** repositorio ignora `.pulse/` y git jamás reportó esos archivos; en un proyecto que no lo ignora, la función entera no existía. Ahora la carpeta de borrador de un turno no es zona prohibida **para ese turno**, y solo para ese: todo lo demás bajo `.pulse` se sigue revirtiendo, y hay una prueba con las dos mitades.
|
|
14
|
+
- **El resaltador tomaba el `//` de un regex como comentario** (#6). `/^https?:\/\//` dejaba gris el resto de la línea. Un literal tiene que ser uno de los candidatos que el escáner considera, no algo que revise después — si salta primero al `//`, nunca se entera de que había un literal abierto. Y el error contrario sería peor, así que `a / b / c` sigue siendo una división: una barra abre un literal solo donde puede empezar un valor, y una que no cierra en su línea era división desde el principio.
|
|
15
|
+
- **Dos textos en inglés** (#7). El vendor de `@madre` —«the room · local»— nunca pasó por el catálogo, y quedó a la vista cuando el menú de revisar empezó a mostrarlo. Y la franja de CREATE decía `@CODEX MAY CREA, GENERA IMÁGENES`: los verbos traducidos dentro de una frase que no. Una frase se traduce entera o no se traduce.
|
|
16
|
+
- **El centinela deja de archivar lo que MU/TH/UR ya explica** (#4). El reporte que llegó al repositorio era `Reading additional input from stdin...`, que no es una falla sino un aviso de Codex — llegó ahí porque MADRE entregaba lo último de stderr como si fuera la causa. Desde 0.4.1 el catálogo lo conoce (`MU-002`) y el centinela no archiva lo que el catálogo explica, así que se explica en la sala en vez de enviárselo al autor. Hay una prueba con el texto exacto de ese reporte.
|
|
17
|
+
- De paso, la tercera zona muerta temporal de la semana: el mapa de vendors quedó declarado al fondo del archivo y la fila de agentes se pinta durante el arranque. **Esta vez la atrapó la prueba de humo**, que es para lo que se reforzó.
|
|
18
|
+
|
|
7
19
|
## 0.5.0 · 2026-09-28
|
|
8
20
|
|
|
9
21
|
**Por qué menor y no parche.** Cambia a dónde sale lo que sale de tu máquina —el colector vive ahora bajo `madre.run`— y, sobre todo, **puede impedir que algo salga**: si tienes `OPENAI_BASE_URL` o `ANTHROPIC_BASE_URL` apuntando a un gateway, esos turnos quedan detenidos hasta que quites la variable o autorices ese endpoint con `PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS`. Es el arreglo correcto, pero un número de parche se lee como «actualiza sin mirar», y este no lo es. El ledger y la memoria de 0.4.x se siguen leyendo igual.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jossuealcala/madre",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
@@ -141,6 +141,11 @@ const failureKey = (failure) => `${failure.time ?? ''}|${failure.agent ?? ''}|${
|
|
|
141
141
|
const saveDismissed = () => { try { localStorage.setItem('pulse.mother.dismissed', JSON.stringify([...dismissed].slice(-400))); } catch { /* no storage */ } };
|
|
142
142
|
const openFailures = () => state.failures.filter((failure) => !failure.recovered && !dismissed.has(failureKey(failure)));
|
|
143
143
|
|
|
144
|
+
// Every other vendor is a company and a company is called the same everywhere. @madre's is a
|
|
145
|
+
// sentence, so it is said in the room's language — declared, not computed, so both guards see it.
|
|
146
|
+
const VENDOR_WORDS = { 'the room · local': t('the room · local'), local: t('local') };
|
|
147
|
+
const vendorWord = (id) => { const said = brandOf(id).vendor ?? ''; return VENDOR_WORDS[said] ?? said; };
|
|
148
|
+
|
|
144
149
|
// Which agent last wrote to a path, taken from what the room already recorded: the artifacts a
|
|
145
150
|
// creation lease produced, and the files a CONTROL turn changed. Nothing new is stored and nothing
|
|
146
151
|
// is inferred from the filesystem — if MADRE did not see it happen, the file carries no mark.
|
|
@@ -368,7 +373,7 @@ function showViewerMenu(x, y) {
|
|
|
368
373
|
item.type = 'button';
|
|
369
374
|
// `@codex Codex` says one thing twice. The handle is the name; beside it goes only what the
|
|
370
375
|
// handle does not already tell you — the vendor, or why it cannot take this.
|
|
371
|
-
const aside = agent.ready ? (
|
|
376
|
+
const aside = agent.ready ? vendorWord(agent.id) : t('no session');
|
|
372
377
|
item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
|
|
373
378
|
item.disabled = !agent.ready;
|
|
374
379
|
item.addEventListener('click', () => reviewWith(agent.id));
|
|
@@ -774,7 +779,7 @@ function renderAgents() {
|
|
|
774
779
|
status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline',
|
|
775
780
|
});
|
|
776
781
|
node.title = [
|
|
777
|
-
`${agent.label}${
|
|
782
|
+
`${agent.label}${vendorWord(agent.id) ? ` · ${vendorWord(agent.id)}` : ''}`,
|
|
778
783
|
agent.ready ? t('Ready') : agent.detected ? t('Detected, adapter pending') : t('Not installed'),
|
|
779
784
|
agent.version ? agent.version : null,
|
|
780
785
|
official === null
|
|
@@ -1144,7 +1149,7 @@ function bridgeCard(agent) {
|
|
|
1144
1149
|
head.append(avatar(agent.id, { size: 30, status: stage === 'ready' ? 'ready' : agent.detected ? 'detected' : 'offline' }));
|
|
1145
1150
|
const title = el('div', 'title');
|
|
1146
1151
|
title.append(el('b', null, agent.label));
|
|
1147
|
-
title.append(el('span', 'vendor',
|
|
1152
|
+
title.append(el('span', 'vendor', vendorWord(agent.id)));
|
|
1148
1153
|
head.append(title);
|
|
1149
1154
|
head.append(el('span', `state ${stage}`, stage === 'missing' ? t('NOT INSTALLED') : stage === 'inert' ? t('NO ADAPTER') : stage === 'route-blocked' ? t('ROUTE BLOCKED') : stage === 'ready' ? t('READY') : t('SIGNED OUT')));
|
|
1150
1155
|
card.append(head);
|
|
@@ -1503,7 +1508,7 @@ function showReplyMenu(source, x, y) {
|
|
|
1503
1508
|
item.type = 'button';
|
|
1504
1509
|
// The same twice-said name as the viewer's menu: the handle IS the name. Beside it goes only
|
|
1505
1510
|
// what the handle does not carry — that this is the one who just spoke, its vendor, or why not.
|
|
1506
|
-
const aside = !agent.ready ? t('no session') : agent.id === source.sender ? t('the same agent') : (
|
|
1511
|
+
const aside = !agent.ready ? t('no session') : agent.id === source.sender ? t('the same agent') : vendorWord(agent.id);
|
|
1507
1512
|
item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
|
|
1508
1513
|
item.disabled = !agent.ready;
|
|
1509
1514
|
item.addEventListener('click', () => replyWith(agent.id, source));
|
|
@@ -2156,7 +2161,10 @@ function renderLease(event) {
|
|
|
2156
2161
|
node.style.setProperty('--agent', agentColor(agent));
|
|
2157
2162
|
const { delegated = false, scratchDir = null, grantedBy = null } = event.payload;
|
|
2158
2163
|
node.append(el('b', null, standing ? t('default #2 · ') : escalated ? `${t('#2 granted on request')}${escalated === 'plan' ? t(' · whole plan') : ''} · ` : delegated ? t('#2 by @{who} · ', { who: grantedBy }) : t('create · ')));
|
|
2159
|
-
|
|
2164
|
+
// The verbs were translated and the sentence holding them was not: «@CODEX MAY CREA, GENERA
|
|
2165
|
+
// IMÁGENES». A sentence is translated whole or it is not translated.
|
|
2166
|
+
const can = scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ');
|
|
2167
|
+
node.append(`${t('@{agent} may {what}', { agent, what: can || t('create files') })}${unavailable.length ? t(' (cannot {what})', { what: unavailable.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') }) : ''} `);
|
|
2160
2168
|
if (outDir === '.' || !outDir) {
|
|
2161
2169
|
node.append(t('anywhere in the project · existing files stay untouched'));
|
|
2162
2170
|
if (scratchDir) { node.append(t(' · scratch ')); const link = el('a', 'file-link', scratchDir); link.href = '#'; link.addEventListener('click', (ev) => { ev.preventDefault(); }); node.append(link); }
|
|
@@ -4699,7 +4707,7 @@ function openAgentPop(id, anchor) {
|
|
|
4699
4707
|
const head = el('div', 'head');
|
|
4700
4708
|
head.append(avatar(id, { size: 24 }));
|
|
4701
4709
|
head.append(el('b', null, agent.label));
|
|
4702
|
-
head.append(el('span', 'vendor',
|
|
4710
|
+
head.append(el('span', 'vendor', vendorWord(id)));
|
|
4703
4711
|
pop.append(head);
|
|
4704
4712
|
const big = el('div', 'big', official === null ? (formatTokens(agent.tokens ?? 0) || '0') : `${Math.round(official)}%`);
|
|
4705
4713
|
big.append(el('small', null, official === null ? t('budget tokens · local 5h window') : t("of the provider's {window} limit", { window: fmtWindow(agent.officialWindows?.primary?.windowMinutes ?? 300) }) + (agent.officialResetAt ? t(' · resets {when}', { when: fmtReset(agent.officialResetAt) }) : t(' · window reset'))));
|
package/public/es.js
CHANGED
|
@@ -1805,6 +1805,10 @@ export const ES = {
|
|
|
1805
1805
|
'/module writes a file, so it needs #2 CREATE. Your request is still in the composer.':
|
|
1806
1806
|
'/module escribe un archivo, así que necesita #2 CREATE. Tu petición sigue en el compositor.',
|
|
1807
1807
|
'ARM #2 CREATE': 'ARMAR #2 CREATE',
|
|
1808
|
+
'the room · local': 'la sala · local',
|
|
1809
|
+
'@{agent} may {what}': '@{agent} puede {what}',
|
|
1810
|
+
' (cannot {what})': ' (no puede {what})',
|
|
1811
|
+
'create files': 'crear archivos',
|
|
1808
1812
|
'review it with': 'revisarlo con',
|
|
1809
1813
|
'no session': 'sin sesión',
|
|
1810
1814
|
'CLICK A LINE NUMBER TO SELECT · SHIFT FOR A RANGE':
|
package/public/styles.css
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
--syn-comment: color-mix(in srgb, var(--text-3) 88%, transparent);
|
|
51
51
|
--syn-number: #ffd166;
|
|
52
52
|
--syn-literal: #ff9ecd;
|
|
53
|
+
--syn-regex: #7ce0d3;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
@media (prefers-color-scheme: light) {
|
|
@@ -60,6 +61,7 @@
|
|
|
60
61
|
--syn-comment: #6a7280;
|
|
61
62
|
--syn-number: #9a5000;
|
|
62
63
|
--syn-literal: #9c1f6b;
|
|
64
|
+
--syn-regex: #0a6a73;
|
|
63
65
|
--bg: #f4f5f2;
|
|
64
66
|
--bg-2: #ffffff;
|
|
65
67
|
--panel: #ffffff;
|
|
@@ -92,6 +94,7 @@
|
|
|
92
94
|
--syn-comment: #6a7280;
|
|
93
95
|
--syn-number: #9a5000;
|
|
94
96
|
--syn-literal: #9c1f6b;
|
|
97
|
+
--syn-regex: #0a6a73;
|
|
95
98
|
--bg: #f4f5f2;
|
|
96
99
|
--bg-2: #ffffff;
|
|
97
100
|
--panel: #ffffff;
|
|
@@ -1733,4 +1736,5 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
|
|
|
1733
1736
|
.viewer .code .t-comment { color: var(--syn-comment); font-style: italic; }
|
|
1734
1737
|
.viewer .code .t-number { color: var(--syn-number); }
|
|
1735
1738
|
.viewer .code .t-literal { color: var(--syn-literal); }
|
|
1739
|
+
.viewer .code .t-regex { color: var(--syn-regex); }
|
|
1736
1740
|
.viewer-lang { color: var(--ph-dim); font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase; }
|
package/public/syntax.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// are the same bytes. Anything it cannot classify stays plain text rather than being guessed at.
|
|
11
11
|
|
|
12
12
|
const FAMILIES = {
|
|
13
|
-
js: { ext: /\.(m|c)?jsx?$|\.tsx?$/i, label: 'JavaScript',
|
|
13
|
+
js: { ext: /\.(m|c)?jsx?$|\.tsx?$/i, label: 'JavaScript', regex: true,
|
|
14
14
|
keywords: /\b(await|async|break|case|catch|class|const|continue|default|delete|do|else|export|extends|finally|for|from|function|get|if|import|in|instanceof|let|new|of|return|set|static|super|switch|this|throw|try|typeof|var|void|while|yield|as|interface|type|enum|implements|readonly|declare|namespace|satisfies)\b/,
|
|
15
15
|
literals: /\b(true|false|null|undefined|NaN|Infinity)\b/,
|
|
16
16
|
line: /\/\/.*/, block: [/\/\*/, /\*\//], strings: ['"', "'", '`'] },
|
|
@@ -73,11 +73,14 @@ export function tokenize(line, family, state = { inBlock: false }) {
|
|
|
73
73
|
const lineOpen = rest.match(family.line);
|
|
74
74
|
const quote = family.strings.map((q) => rest.indexOf(q)).filter((at) => at >= 0).sort((a, b) => a - b)[0];
|
|
75
75
|
|
|
76
|
-
// Whichever comes first on this line decides what happens next.
|
|
76
|
+
// Whichever comes first on this line decides what happens next. A regular expression has to
|
|
77
|
+
// be among the candidates and not an afterthought: its own `//` is a line comment to every
|
|
78
|
+
// other rule here, so if the scan jumps to that `//` first it never learns a literal was open.
|
|
77
79
|
const marks = [
|
|
78
80
|
blockOpen && blockOpen.index === 0 ? { at: 0, what: 'block' } : blockOpen ? { at: blockOpen.index, what: 'block' } : null,
|
|
79
81
|
lineOpen && lineOpen.index !== undefined ? { at: lineOpen.index, what: 'line' } : null,
|
|
80
82
|
quote !== undefined ? { at: quote, what: 'string' } : null,
|
|
83
|
+
family.regex ? regexMark(rest, line, index, out) : null,
|
|
81
84
|
].filter(Boolean).sort((a, b) => a.at - b.at);
|
|
82
85
|
const next = marks[0];
|
|
83
86
|
|
|
@@ -93,6 +96,11 @@ export function tokenize(line, family, state = { inBlock: false }) {
|
|
|
93
96
|
push(rest, 'comment'); index = line.length; state.inBlock = true; break;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
if (next.what === 'regex') {
|
|
100
|
+
const end = regexEnd(rest);
|
|
101
|
+
if (end) { push(rest.slice(0, end), 'regex'); index += end; continue; }
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
// A string: to its closing quote, respecting a backslash escape.
|
|
97
105
|
const q = rest[0];
|
|
98
106
|
let at = 1;
|
|
@@ -107,6 +115,52 @@ export function tokenize(line, family, state = { inBlock: false }) {
|
|
|
107
115
|
return out;
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
// The first slash on what is left that could open a regular expression, and only if the literal
|
|
119
|
+
// actually closes on this line: an unclosed one was division after all.
|
|
120
|
+
function regexMark(rest, line, offset, pieces) {
|
|
121
|
+
for (let at = 0; at < rest.length; at += 1) {
|
|
122
|
+
if (rest[at] !== '/') continue;
|
|
123
|
+
if (rest[at + 1] === '/' || rest[at + 1] === '*') return null; // a comment, not a literal
|
|
124
|
+
const before = line.slice(0, offset + at);
|
|
125
|
+
if (!canStartRegex(pieces, before)) continue;
|
|
126
|
+
return regexEnd(rest.slice(at)) ? { at, what: 'regex' } : null;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A slash opens a regular expression only where a value may begin. After a name, a number or a
|
|
132
|
+
// closing bracket it is division, and treating `a / b / c` as a literal would be worse than the
|
|
133
|
+
// bug this fixes. Whitespace does not count as the thing before.
|
|
134
|
+
function canStartRegex(pieces, before = '') {
|
|
135
|
+
const trimmed = before.trimEnd();
|
|
136
|
+
if (trimmed) return !/[\w$)\]]$/.test(trimmed);
|
|
137
|
+
for (let at = pieces.length - 1; at >= 0; at -= 1) {
|
|
138
|
+
const text = (pieces[at].text ?? '').trimEnd();
|
|
139
|
+
if (!text) continue;
|
|
140
|
+
if (pieces[at].kind === 'comment') continue;
|
|
141
|
+
if (pieces[at].kind === 'string' || pieces[at].kind === 'number') return false;
|
|
142
|
+
return !/[\w$)\]]$/.test(text);
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Where the literal ends: the closing slash plus its flags, skipping escapes and anything inside
|
|
148
|
+
// a character class, where an unescaped slash is ordinary. Returns 0 when it never closes, and
|
|
149
|
+
// then the slash was division after all.
|
|
150
|
+
function regexEnd(rest) {
|
|
151
|
+
let at = 1;
|
|
152
|
+
let inClass = false;
|
|
153
|
+
while (at < rest.length) {
|
|
154
|
+
const char = rest[at];
|
|
155
|
+
if (char === '\\') { at += 2; continue; }
|
|
156
|
+
if (inClass) { if (char === ']') inClass = false; at += 1; continue; }
|
|
157
|
+
if (char === '[') { inClass = true; at += 1; continue; }
|
|
158
|
+
if (char === '/') { at += 1; while (at < rest.length && /[a-z]/i.test(rest[at])) at += 1; return at; }
|
|
159
|
+
at += 1;
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
110
164
|
// Code between the comments and the strings: keywords, literals, numbers, and the rest plain.
|
|
111
165
|
function pushCode(chunk, family, push) {
|
|
112
166
|
const pattern = new RegExp(`${family.keywords.source}|${family.literals.source}|\\b\\d+(?:\\.\\d+)?\\b`, family.keywords.flags.includes('i') ? 'gi' : 'g');
|
package/src/room/control.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export class ControlDesk {
|
|
|
29
29
|
this.#checkpoints.set(checkpoint.id, checkpoint);
|
|
30
30
|
// Prevention first: .env files and MADRE's folders are read-only for the length of the turn.
|
|
31
31
|
const guard = await guardForbidden(this.#projectRoot);
|
|
32
|
-
const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard, mode };
|
|
32
|
+
const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard, mode, scratchDir: null };
|
|
33
33
|
if (mode >= 3) this.#holder = run;
|
|
34
34
|
const lease = { leaseId: checkpoint.id, outDir: this.#projectRoot, relativeDir: '.', scopes: { ...enabledScopes, write: true }, control: mode >= 3, airlock: mode === 4, create: mode === 2, checkpoint };
|
|
35
35
|
if (mode < 3) return { run, lease, announcement: null };
|
|
@@ -43,7 +43,14 @@ export class ControlDesk {
|
|
|
43
43
|
async settle(run) {
|
|
44
44
|
const diff = await diffCheckpoint(this.#projectRoot, run.checkpoint);
|
|
45
45
|
const additive = run.mode === 2;
|
|
46
|
-
|
|
46
|
+
// The draft folder MADRE itself hands the agent lives under `.pulse/out/`, and `.pulse/` is a
|
|
47
|
+
// forbidden zone — so the room named a place to write, the agent wrote there, and the room
|
|
48
|
+
// undid it. Nobody noticed here because this project ignores `.pulse/` and git never reported
|
|
49
|
+
// the files; in a project that does not, `/module` never reached its install card. A turn's
|
|
50
|
+
// own scratch folder is not a forbidden zone FOR THAT TURN. Every other `.pulse` path still is.
|
|
51
|
+
const ownScratch = run.scratchDir ? `${run.scratchDir.replace(/\/$/, '')}/` : null;
|
|
52
|
+
const mine = (path) => Boolean(ownScratch) && path.startsWith(ownScratch);
|
|
53
|
+
const toRevert = new Set(diff.forbidden.filter((path) => !mine(path)));
|
|
47
54
|
if (additive) for (const file of diff.files) if (file.status !== 'A') toRevert.add(file.path);
|
|
48
55
|
let reverted = [];
|
|
49
56
|
if (toRevert.size) {
|
|
@@ -51,7 +58,7 @@ export class ControlDesk {
|
|
|
51
58
|
reverted = [...restored.restored, ...restored.removed];
|
|
52
59
|
}
|
|
53
60
|
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:
|
|
61
|
+
const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, forbiddenReverted: reverted.filter((path) => diff.forbidden.includes(path) && !mine(path)), existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [] };
|
|
55
62
|
const count = changes.files.length;
|
|
56
63
|
const notes = [
|
|
57
64
|
changes.forbiddenReverted.length ? `${changes.forbiddenReverted.length} write(s) into forbidden zones were reverted.` : null,
|
package/src/room.mjs
CHANGED
|
@@ -1197,6 +1197,9 @@ export class Room {
|
|
|
1197
1197
|
// CREATE: photograph the project now; after the turn only what appeared stays.
|
|
1198
1198
|
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2 });
|
|
1199
1199
|
createRun = seat.run;
|
|
1200
|
+
// The folder this turn was told to draft in, so settling it does not undo what it was
|
|
1201
|
+
// invited to write. Only this turn's own folder; every other `.pulse` path stays forbidden.
|
|
1202
|
+
createRun.scratchDir = lease.scratchDir ?? null;
|
|
1200
1203
|
lease = { ...lease, checkpoint: seat.run.checkpoint };
|
|
1201
1204
|
}
|
|
1202
1205
|
turnScopes.imageGen = Boolean(lease?.scopes?.imageGen);
|