@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 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.0",
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 ? (brandOf(agent.id).vendor ?? '') : t('no session');
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}${brandOf(agent.id).vendor ? ` · ${brandOf(agent.id).vendor}` : ''}`,
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', brandOf(agent.id).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') : (brandOf(agent.id).vendor ?? '');
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
- node.append(`@${agent} may ${scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') || 'create files'}${unavailable.length ? ` (cannot ${unavailable.join(', ')})` : ''} `);
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', brandOf(id).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');
@@ -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
- const toRevert = new Set(diff.forbidden);
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: diff.forbidden.length ? reverted.filter((path) => diff.forbidden.includes(path)) : [], existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [] };
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);