@jossuealcala/madre 0.7.2 → 1.0.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.
Files changed (172) hide show
  1. package/CHANGELOG.md +817 -1
  2. package/CONTRIBUTING.md +5 -1
  3. package/NOTICE +20 -1
  4. package/README.md +99 -34
  5. package/TRADEMARKS.md +65 -0
  6. package/bin/madre.mjs +30 -10
  7. package/docs/INTERNALS.md +2 -2
  8. package/docs/REFERENCE.md +159 -82
  9. package/docs/SDK.md +23 -19
  10. package/docs/madre-banner.png +0 -0
  11. package/package.json +4 -3
  12. package/public/app.js +11668 -4219
  13. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  14. package/public/assets/brand/Geist-latin.woff2 +0 -0
  15. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  16. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  17. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  18. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  19. package/public/assets/brand/OFL-Geist.txt +93 -0
  20. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  21. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  22. package/public/assets/brand/OFL-Sora.txt +93 -0
  23. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  24. package/public/assets/brand/Sora-latin.woff2 +0 -0
  25. package/public/assets/brand/madre-app-180.png +0 -0
  26. package/public/assets/brand/madre-app-192.png +0 -0
  27. package/public/assets/brand/madre-app-512.png +0 -0
  28. package/public/assets/brand/madre-app.svg +1 -0
  29. package/public/assets/brand/madre-favicon.svg +1 -0
  30. package/public/assets/news/1.0/alerts.webp +0 -0
  31. package/public/assets/news/1.0/bar.webp +0 -0
  32. package/public/assets/news/1.0/finder.webp +0 -0
  33. package/public/assets/news/1.0/github.webp +0 -0
  34. package/public/assets/news/1.0/intents.webp +0 -0
  35. package/public/assets/news/1.0/looks.webp +0 -0
  36. package/public/assets/news/1.0/modules.webp +0 -0
  37. package/public/assets/news/1.0/mother.webp +0 -0
  38. package/public/assets/news/1.0/nostromo.webp +0 -0
  39. package/public/assets/news/1.0/permissions.webp +0 -0
  40. package/public/assets/news/1.0/room.webp +0 -0
  41. package/public/assets/news/1.0/settings.webp +0 -0
  42. package/public/assets/wallpapers/biznaga.webp +0 -0
  43. package/public/assets/wallpapers/canela.webp +0 -0
  44. package/public/assets/wallpapers/jarrito.webp +0 -0
  45. package/public/assets/wallpapers/magna.webp +0 -0
  46. package/public/assets/wallpapers/oceano.webp +0 -0
  47. package/public/assets/wallpapers/paraiso.webp +0 -0
  48. package/public/assets/wallpapers/quila.webp +0 -0
  49. package/public/assets/wallpapers/tradicion.webp +0 -0
  50. package/public/assets/wallpapers/tropical.webp +0 -0
  51. package/public/assets/wallpapers/urbania.webp +0 -0
  52. package/public/assets/wallpapers/ventura.webp +0 -0
  53. package/public/assets/wallpapers/xico.webp +0 -0
  54. package/public/brands.js +28 -2
  55. package/public/editor.js +744 -0
  56. package/public/es.js +3443 -1425
  57. package/public/i18n.js +1 -1
  58. package/public/index.html +201 -175
  59. package/public/inquiry.js +11 -11
  60. package/public/loader.js +79 -0
  61. package/public/macroverso.js +1517 -0
  62. package/public/manifest.webmanifest +14 -0
  63. package/public/nostromo-engine.js +2222 -0
  64. package/public/resay.js +24 -0
  65. package/public/review-ask.js +85 -0
  66. package/public/salas-mac.css +1281 -0
  67. package/public/salas-mac.html +158 -0
  68. package/public/salas-mac.js +4593 -0
  69. package/public/salas.css +85 -0
  70. package/public/salas.html +47 -0
  71. package/public/salas.js +200 -0
  72. package/public/styles.css +6372 -426
  73. package/public/touch.js +83 -0
  74. package/public/tour-chapters.js +452 -0
  75. package/public/tour.js +546 -0
  76. package/public/troubleshooting.js +160 -141
  77. package/src/adapters/codex.mjs +46 -6
  78. package/src/adapters/gemini.mjs +30 -10
  79. package/src/adapters/madre.mjs +110 -18
  80. package/src/adapters/process.mjs +42 -2
  81. package/src/auth-probe.mjs +31 -8
  82. package/src/bridge/devices.mjs +202 -0
  83. package/src/bridge/handoff.mjs +45 -0
  84. package/src/bridge/host.mjs +82 -0
  85. package/src/bridge/network.mjs +133 -0
  86. package/src/bridge/policy.mjs +89 -0
  87. package/src/bridge/qr.mjs +287 -0
  88. package/src/bridge/server.mjs +269 -0
  89. package/src/capabilities.mjs +5 -9
  90. package/src/chats.mjs +59 -22
  91. package/src/checkpoint.mjs +137 -35
  92. package/src/choices.mjs +18 -3
  93. package/src/cli-args.mjs +12 -1
  94. package/src/commands.mjs +15 -15
  95. package/src/config.mjs +73 -9
  96. package/src/conversation-context.mjs +5 -2
  97. package/src/credentials.mjs +80 -0
  98. package/src/dataset.mjs +3 -1
  99. package/src/directives.mjs +6 -11
  100. package/src/enlaces.mjs +451 -0
  101. package/src/event-store.mjs +55 -10
  102. package/src/exam.mjs +5 -5
  103. package/src/extensions.mjs +31 -12
  104. package/src/file-ops.mjs +23 -0
  105. package/src/files.mjs +52 -2
  106. package/src/github.mjs +407 -0
  107. package/src/heartbeat.mjs +55 -0
  108. package/src/json-file.mjs +63 -0
  109. package/src/lease.mjs +2 -2
  110. package/src/lobby-work.mjs +103 -0
  111. package/src/local-ai.mjs +48 -0
  112. package/src/looks.mjs +88 -0
  113. package/src/maturity.mjs +3 -3
  114. package/src/mcp/github-server.mjs +164 -0
  115. package/src/mcp/image-server.mjs +1 -1
  116. package/src/mcp/memory-server.mjs +11 -3
  117. package/src/memory.mjs +38 -12
  118. package/src/models.mjs +127 -5
  119. package/src/modules/ahp.mjs +4 -4
  120. package/src/modules/ash.mjs +1 -1
  121. package/src/modules/bishop-plan.mjs +207 -0
  122. package/src/modules/bishop.mjs +645 -0
  123. package/src/modules/choices.mjs +2 -2
  124. package/src/modules/correo.mjs +1 -1
  125. package/src/modules/enlaces.mjs +255 -0
  126. package/src/modules/git-pulse.mjs +2 -2
  127. package/src/modules/github.mjs +322 -0
  128. package/src/modules/image-studio.mjs +3 -3
  129. package/src/modules/index.mjs +18 -4
  130. package/src/modules/intents-calibration.mjs +338 -0
  131. package/src/modules/intents-desk.mjs +481 -0
  132. package/src/modules/intents-observe.mjs +266 -0
  133. package/src/modules/intents-policy.mjs +414 -0
  134. package/src/modules/intents-try.mjs +91 -0
  135. package/src/modules/intents.mjs +145 -0
  136. package/src/modules/ollama.mjs +18 -6
  137. package/src/modules/playwright.mjs +121 -11
  138. package/src/modules/ripley.mjs +1 -1
  139. package/src/modules/runs.mjs +2 -2
  140. package/src/modules/sdk.mjs +10 -4
  141. package/src/ollama.mjs +6 -1
  142. package/src/outbound.mjs +36 -17
  143. package/src/override.mjs +100 -0
  144. package/src/ports.mjs +23 -1
  145. package/src/prices.mjs +72 -0
  146. package/src/privacy.mjs +30 -11
  147. package/src/quota-sources.mjs +0 -8
  148. package/src/request-guard.mjs +1 -1
  149. package/src/review-ask.mjs +111 -0
  150. package/src/room/archivist.mjs +55 -37
  151. package/src/room/budget.mjs +17 -5
  152. package/src/room/control.mjs +70 -7
  153. package/src/room/economy.mjs +35 -7
  154. package/src/room/escalation.mjs +3 -0
  155. package/src/room/guard.mjs +26 -2
  156. package/src/room/prompt.mjs +22 -15
  157. package/src/room/runs.mjs +84 -21
  158. package/src/room/vectors.mjs +19 -4
  159. package/src/room.mjs +411 -84
  160. package/src/router.mjs +3 -1
  161. package/src/runs.mjs +23 -5
  162. package/src/salas.mjs +313 -0
  163. package/src/sentinel-errors.mjs +1 -1
  164. package/src/server-sentinel.mjs +124 -0
  165. package/src/server.mjs +1864 -178
  166. package/src/setup.mjs +1 -0
  167. package/src/tour.mjs +174 -0
  168. package/src/updates.mjs +48 -8
  169. package/src/usage-sentinel.mjs +1 -1
  170. package/src/verdict.mjs +7 -7
  171. package/src/whats-new.mjs +160 -0
  172. package/docs/madre-banner.svg +0 -25
package/src/config.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { copyFile, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
3
  import { homedir } from 'node:os';
3
4
  import { dirname, join } from 'node:path';
4
5
 
@@ -18,21 +19,84 @@ export async function loadConfig(root) {
18
19
  }
19
20
  }
20
21
 
22
+ // Every room on this Mac writes this one file. A write is whole — a draft renamed over the file,
23
+ // so nobody ever reads half of one — and a change is read, applied and written under a lock, so
24
+ // two rooms saving at once keep both changes instead of the last one writing over the other
25
+ // (docs/MACROVERSO.md, S-001).
21
26
  export async function saveConfig(root, config) {
22
27
  const file = configPath(root);
23
28
  await mkdir(dirname(file), { recursive: true });
24
- await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
29
+ const draft = `${file}.${process.pid}.${randomUUID()}.tmp`;
30
+ await writeFile(draft, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
31
+ await rename(draft, file);
25
32
  return file;
26
33
  }
27
34
 
28
- export async function updateConfig(root, patch) {
29
- const current = await loadConfig(root);
30
- const next = { ...current, ...patch };
31
- for (const [key, value] of Object.entries(patch)) {
32
- if (value && typeof value === 'object' && !Array.isArray(value)) next[key] = { ...(current[key] ?? {}), ...value };
35
+ async function locked(root, work) {
36
+ const lock = join(dirname(configPath(root)), 'config.lock');
37
+ await mkdir(dirname(lock), { recursive: true });
38
+ let held = false;
39
+ for (let attempt = 0; attempt < 250 && !held; attempt += 1) {
40
+ try {
41
+ await (await open(lock, 'wx')).close();
42
+ held = true;
43
+ } catch (error) {
44
+ if (error.code !== 'EEXIST') throw error;
45
+ // A lock left by a process that died mid-write is stale after a few seconds.
46
+ const since = await stat(lock).catch(() => null);
47
+ if (since && Date.now() - since.mtimeMs > 5000) { await rm(lock, { force: true }); continue; }
48
+ await new Promise((resolve) => setTimeout(resolve, 20));
49
+ }
50
+ }
51
+ if (!held) throw new Error('The settings are being saved by another room; try again.');
52
+ try {
53
+ return await work();
54
+ } finally {
55
+ await rm(lock, { force: true });
33
56
  }
34
- await saveConfig(root, next);
35
- return next;
57
+ }
58
+
59
+ // What a change starts from. A file that is there but is not JSON used to be read as empty, and the
60
+ // change written over it kept only itself: every other setting was gone, with nothing said
61
+ // (2026-10-12, review of 1.0). Now a copy of it is kept beside it first, and that is said.
62
+ async function readForChange(root) {
63
+ const file = configPath(root);
64
+ let text;
65
+ try { text = await readFile(file, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
66
+ try {
67
+ const parsed = JSON.parse(text);
68
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
69
+ } catch { /* kept below */ }
70
+ const kept = `${file}.damaged-${new Date().toISOString().replace(/[:.]/g, '-')}`;
71
+ await copyFile(file, kept).catch(() => {});
72
+ console.error(`MADRE: ${file} could not be read; a copy was kept at ${kept} before saving over it.`);
73
+ return {};
74
+ }
75
+
76
+ // A change that reads what is there and decides from it, all under the lock: checked and written
77
+ // in one step, so two changes at once cannot both pass a check that only one of them should
78
+ // (two signatures of the same pair, a revocation and a recall crossing). `change` gets a copy and
79
+ // returns what to save, or nothing to leave the file as it is.
80
+ export async function withConfig(root, change) {
81
+ return locked(root, async () => {
82
+ const current = await readForChange(root);
83
+ const next = await change(structuredClone(current));
84
+ if (!next || typeof next !== 'object') return current;
85
+ await saveConfig(root, next);
86
+ return next;
87
+ });
88
+ }
89
+
90
+ export async function updateConfig(root, patch) {
91
+ return locked(root, async () => {
92
+ const current = await readForChange(root);
93
+ const next = { ...current, ...patch };
94
+ for (const [key, value] of Object.entries(patch)) {
95
+ if (value && typeof value === 'object' && !Array.isArray(value)) next[key] = { ...(current[key] ?? {}), ...value };
96
+ }
97
+ await saveConfig(root, next);
98
+ return next;
99
+ });
36
100
  }
37
101
 
38
102
  // Applies file-backed preferences to the process environment without
@@ -41,12 +41,15 @@ export function buildConversationContext(events, { excludeMessageId, maxChars =
41
41
  for (let i = from; i < messages.length; i += 1) used += messages[i].sender.length + messages[i].role.length + messages[i].text.length + 4;
42
42
  return used;
43
43
  };
44
- let start = 0;
44
+ // -1 is «no anchor held»: an anchor on the very first message is index 0, and `!start` read it
45
+ // as none, so the window was cut early and the human's first request was the first to go
46
+ // (LOG-15 of the 1.0 review).
47
+ let start = -1;
45
48
  if (Number.isInteger(anchor)) {
46
49
  const held = messages.findIndex((message) => message.sequence >= anchor);
47
50
  if (held >= 0 && weigh(held) <= budget) start = held;
48
51
  }
49
- if (!start || weigh(start) > budget) {
52
+ if (start < 0 || weigh(start) > budget) {
50
53
  // Let go of a chunk and then hold: back off until the window sits well under its budget, so
51
54
  // there is room for many turns of new words before it has to move again.
52
55
  start = messages.length;
@@ -38,6 +38,18 @@ export const OPENCODE_PROVIDERS = [
38
38
 
39
39
  // What the room offers for each agent that cannot be signed in with a click.
40
40
  export function keyPlanFor(id) {
41
+ if (id === 'claude') {
42
+ return {
43
+ agent: 'claude',
44
+ label: 'Anthropic API key',
45
+ keyUrl: 'https://console.anthropic.com/settings/keys',
46
+ writesTo: '~/.claude/settings.json',
47
+ // Said plainly because it is true: this file is the Claude CLI's own, not MADRE's. A key
48
+ // there is the one every Claude Code on this computer will use, MADRE's turns included.
49
+ note: 'The key is written where the Claude CLI looks for it, readable only by you. MADRE keeps no copy. It becomes the account Claude Code uses on this computer, here and in your own terminal, until you remove it.',
50
+ providers: null,
51
+ };
52
+ }
41
53
  if (id === 'gemini') {
42
54
  return {
43
55
  agent: 'gemini',
@@ -99,6 +111,33 @@ async function applyGeminiKey(key, { home, probe }) {
99
111
  }
100
112
  }
101
113
 
114
+ // ---- Claude: `env.ANTHROPIC_API_KEY` in its own settings, confirmed by `claude auth status`.
115
+ // The CLI reads that env block before it runs, which is why a key there is a session: its own
116
+ // status answers `api_key` afterwards. Everything else in the file is left as it was.
117
+ async function applyClaudeKey(key, { home, probe }) {
118
+ const dir = join(home, '.claude');
119
+ const file = join(dir, 'settings.json');
120
+ await mkdir(dir, { recursive: true });
121
+ const before = await readIfThere(file);
122
+ try {
123
+ let settings = {};
124
+ try { settings = JSON.parse(before ?? '{}') ?? {}; } catch { settings = {}; }
125
+ if (typeof settings !== 'object' || Array.isArray(settings)) settings = {};
126
+ settings.env = { ...(settings.env ?? {}), ANTHROPIC_API_KEY: key };
127
+ await writeFile(file, `${JSON.stringify(settings, null, 2)}\n`, { mode: OWNER_ONLY });
128
+ await chmod(file, OWNER_ONLY).catch(() => {});
129
+ const state = await probe();
130
+ if (state?.state !== 'signed-in') {
131
+ await restore(file, before);
132
+ return { ok: false, error: `The key was written but Claude still reads as ${state?.state ?? 'unknown'}${state?.detail ? ` (${state.detail})` : ''}. Nothing was changed.` };
133
+ }
134
+ return { ok: true, detail: state.detail ?? 'via API key' };
135
+ } catch (error) {
136
+ await restore(file, before);
137
+ return { ok: false, error: `The key could not be written: ${error.message}` };
138
+ }
139
+ }
140
+
102
141
  // ---- OpenCode: an entry in its auth.json, confirmed by asking `opencode auth list`.
103
142
  async function applyOpenCodeKey(key, { home, provider, executable, run }) {
104
143
  const known = OPENCODE_PROVIDERS.some((item) => item.id === provider);
@@ -139,7 +178,48 @@ const runOpenCode = async (executable, args) => {
139
178
  export async function applyKey({ agent, key, provider = null, home = homedir(), probe, executable = 'opencode', run = runOpenCode }) {
140
179
  const checked = normalizeKey(key);
141
180
  if (!checked.ok) return checked;
181
+ if (agent === 'claude') return applyClaudeKey(checked.key, { home, probe });
142
182
  if (agent === 'gemini') return applyGeminiKey(checked.key, { home, probe });
143
183
  if (agent === 'opencode') return applyOpenCodeKey(checked.key, { home, provider, executable, run });
144
184
  return { ok: false, error: `${agent} does not take a key here: it signs in with a click.` };
145
185
  }
186
+
187
+ // ---- OpenCode's providers, as its auth.json says them: who is signed in and how, never the key.
188
+ // `api` is a key (MADRE's or one pasted in a terminal), `oauth` an account signed in from
189
+ // `opencode auth login`. Anything unreadable reads as nobody.
190
+ export async function readOpenCodeProviders({ home = homedir() } = {}) {
191
+ const raw = await readIfThere(join(home, '.local', 'share', 'opencode', 'auth.json'));
192
+ let auth = {};
193
+ try { auth = JSON.parse(raw ?? '{}') ?? {}; } catch { auth = {}; }
194
+ if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return [];
195
+ return Object.entries(auth)
196
+ .filter(([id, entry]) => /^[\w.-]{1,60}$/.test(id) && entry && typeof entry === 'object')
197
+ .map(([id, entry]) => ({ id, type: entry.type === 'api' || entry.type === 'oauth' ? entry.type : 'other' }));
198
+ }
199
+
200
+ // Takes a provider's key out of OpenCode's auth.json, the other entries untouched. Only a key
201
+ // (`api`): an account signed in with OpenCode's own login is signed out where it was signed in.
202
+ // OpenCode is the judge again: if it still lists the provider, the file goes back as it was.
203
+ export async function removeOpenCodeKey({ provider, home = homedir(), executable = 'opencode', run = runOpenCode } = {}) {
204
+ if (typeof provider !== 'string' || !/^[\w.-]{1,60}$/.test(provider)) return { ok: false, error: 'Pick the provider whose key goes.' };
205
+ const file = join(home, '.local', 'share', 'opencode', 'auth.json');
206
+ const before = await readIfThere(file);
207
+ let auth = {};
208
+ try { auth = JSON.parse(before ?? '{}') ?? {}; } catch { return { ok: false, error: 'OpenCode\'s auth.json could not be read, so nothing was changed.' }; }
209
+ if (!auth[provider]) return { ok: false, error: `OpenCode has no key for ${provider}.` };
210
+ if (auth[provider].type !== 'api') return { ok: false, error: `${provider} is signed in with its account, not a key: sign it out with \`opencode auth logout\` in a terminal.` };
211
+ try {
212
+ delete auth[provider];
213
+ await writeFile(file, `${JSON.stringify(auth, null, 2)}\n`, { mode: OWNER_ONLY });
214
+ await chmod(file, OWNER_ONLY).catch(() => {});
215
+ const listed = await run(executable, ['auth', 'list']);
216
+ if (new RegExp(`\\b${provider.replace(/[.-]/g, '\\$&')}\\b`, 'i').test(listed.replace(/auth\.json/gi, ''))) {
217
+ await restore(file, before);
218
+ return { ok: false, error: `OpenCode still lists ${provider}, so MADRE put the file back. Remove it with \`opencode auth logout\` in a terminal.` };
219
+ }
220
+ return { ok: true };
221
+ } catch (error) {
222
+ await restore(file, before);
223
+ return { ok: false, error: `The key could not be removed: ${error.message}` };
224
+ }
225
+ }
package/src/dataset.mjs CHANGED
@@ -48,7 +48,9 @@ export function pairsFromEvents(events, { project = 'project', home, user, priva
48
48
  if (event.type !== 'message.created' || event.ghost || !Number.isInteger(event.sequence)) continue;
49
49
  const p = event.payload ?? {};
50
50
  if (typeof p.text !== 'string' || !p.text.trim()) continue;
51
- if (p.role === 'user') { asks.set(p.messageId, { text: p.originalText ?? p.text, mode: p.mode ?? 1, from: 'you' }); continue; }
51
+ // An order of BISHOP (via 'bishop', docs/MACROVERSO.md D-S10) is a user message the human did
52
+ // not write: it is not their words, so neither it nor its answer becomes a pair.
53
+ if (p.role === 'user') { if (p.via !== 'bishop') asks.set(p.messageId, { text: p.originalText ?? p.text, mode: p.mode ?? 1, from: 'you' }); continue; }
52
54
  if (p.role !== 'assistant') continue;
53
55
  if (p.status === 'delegated') {
54
56
  // An orchestrator's step is a question for the agent it names; its closing turn is not.
@@ -9,9 +9,9 @@
9
9
  //
10
10
  // MADRE runs the steps sequentially as regular room turns, each recorded,
11
11
  // each subject to handoff, budget and timeout, and each stoppable by the
12
- // human. A step addressed to the orchestrator itself becomes its closing
13
- // turn once the others have answered. Delegated agents cannot delegate
14
- // further (depth is capped at one), so a plan always terminates.
12
+ // human. The orchestrator gets a closing turn after the others have answered.
13
+ // Delegated agents cannot delegate further (depth is capped at one); the
14
+ // orchestrator may start another bounded wave after reviewing their results.
15
15
 
16
16
  export const DELEGATION_HELP = (self, others, maxSteps) => [
17
17
  `You may put other agents to work: ${others.map((id) => `@${id}`).join(', ')}.`,
@@ -21,8 +21,8 @@ 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.',
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.`,
24
+ 'A step may name the mode it needs, "@codex #2: create the page" or "@claude #3: fix the router"; MADRE uses the human permission for this turn as the ceiling. A higher mode pauses for the human to unlock it for that agent, and #4 always does. Without a number a step only reads, or creates files when the plan may create; ask for #3 by name.',
25
+ `MADRE runs the steps in order (at most ${maxSteps}), shows every answer in the room, then hands you a closing turn to review progress. If work remains within the human permission, you may end that turn with another pulse plan; at most three waves run from one request.`,
26
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.',
27
27
  'Address each agent once. Do not delegate what you can answer yourself.',
28
28
  ].join('\n');
@@ -47,7 +47,7 @@ export function parseDirectives(text, { self, available = [], maxSteps = 4 } = {
47
47
  const line = raw.trim();
48
48
  if (!line || line.startsWith('#')) continue;
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);
50
+ const match = line.match(/^[-*]?\s*@([a-z0-9_-]+)\s*(?:#([0-4]))?\s*[::]\s*(.+)$/i);
51
51
  if (!match) { ignored.push({ line, reason: 'not a step (expected "@agent: text" or "@agent #2: text")' }); continue; }
52
52
  const agent = match[1].toLowerCase();
53
53
  const instruction = match[3].trim();
@@ -66,8 +66,3 @@ export function parseDirectives(text, { self, available = [], maxSteps = 4 } = {
66
66
  }
67
67
  return { steps, closing, ignored };
68
68
  }
69
-
70
- // The reply text without the plan block, for readers who only want the prose.
71
- export function stripDirectives(text) {
72
- return String(text ?? '').replace(/```pulse\s*\n[\s\S]*?```/gi, '').trim();
73
- }