@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/chats.mjs CHANGED
@@ -15,9 +15,10 @@
15
15
  //
16
16
  // Nothing here starts a room or reads a ledger. It keeps the list.
17
17
 
18
- import { readFile, writeFile, mkdir, rm, readdir, open, stat } from 'node:fs/promises';
18
+ import { readFile, mkdir, rm, readdir, open, stat } from 'node:fs/promises';
19
19
  import { join } from 'node:path';
20
20
  import { randomUUID } from 'node:crypto';
21
+ import { oneAtATime, writeWhole } from './json-file.mjs';
21
22
 
22
23
  export const MAIN_CHAT = 'main';
23
24
  export const CHAT_TITLE_MAX = 80;
@@ -38,11 +39,14 @@ async function readIndex(roomDir) {
38
39
  return null;
39
40
  }
40
41
 
42
+ // Written whole, one change at a time (src/json-file.mjs): a rename, a new conversation and a
43
+ // message arriving read and wrote the list at once, and the last one wrote over the others
44
+ // (LOG-11 of the 1.0 review); and a write cut halfway left a list nobody could read.
41
45
  async function writeIndex(roomDir, index) {
42
- await mkdir(roomDir, { recursive: true }).catch(() => {});
43
- await writeFile(join(roomDir, FILE), JSON.stringify(index, null, 2));
46
+ await writeWhole(join(roomDir, FILE), JSON.stringify(index, null, 2));
44
47
  return index;
45
48
  }
49
+ const indexTurn = (roomDir, work) => oneAtATime(join(roomDir, FILE), work);
46
50
 
47
51
  // The list, with the first conversation always in it. A room that predates conversations reads
48
52
  // as one conversation, which is what it was.
@@ -55,14 +59,22 @@ export async function chatIndex(roomDir) {
55
59
  return index;
56
60
  }
57
61
 
62
+ // A conversation named or previewed by a message BISHOP's channel brought, before the room learned
63
+ // to leave out the header its agent reads: shown without it (docs/MACROVERSO.md, fase 4c).
64
+ const withoutRelayHeader = (line) => {
65
+ if (typeof line !== 'string' || !line.startsWith('BISHOP › ')) return line;
66
+ const task = line.replace(/^BISHOP › .*?(?:other rooms(?: in this turn)?\.|ends with the answer\.)\s*/, '');
67
+ // Cut short inside the header (titles and previews are kept short): only the sender is left.
68
+ return task && !task.startsWith('BISHOP › ') ? task : 'Coordinator';
69
+ };
58
70
  const shape = (id, entry, active) => ({
59
71
  id,
60
- title: entry.title || (id === MAIN_CHAT ? 'First conversation' : 'Untitled'),
72
+ title: withoutRelayHeader(entry.title) || (id === MAIN_CHAT ? 'First conversation' : 'Untitled'),
61
73
  named: Boolean(entry.title),
62
74
  createdAt: entry.createdAt ?? null,
63
75
  updatedAt: entry.updatedAt ?? entry.createdAt ?? null,
64
76
  messages: Number(entry.messages ?? 0),
65
- preview: entry.preview ?? null,
77
+ preview: withoutRelayHeader(entry.preview) ?? null,
66
78
  active: id === active,
67
79
  });
68
80
 
@@ -75,17 +87,24 @@ export async function listChats(roomDir) {
75
87
  return { active: index.active, chats };
76
88
  }
77
89
 
78
- export async function createChat(roomDir, { title = null, now = new Date().toISOString() } = {}) {
90
+ export function createChat(roomDir, ...rest) {
91
+ return indexTurn(roomDir, () => createChatNow(roomDir, ...rest));
92
+ }
93
+ async function createChatNow(roomDir, { title = null, now = new Date().toISOString(), activate = true } = {}) {
79
94
  const index = await chatIndex(roomDir);
80
95
  const id = randomUUID().replace(/-/g, '').slice(0, 16);
81
96
  index.chats[id] = { title: title ? String(title).slice(0, CHAT_TITLE_MAX) : null, createdAt: now, updatedAt: now, messages: 0, preview: null };
82
- index.active = id;
97
+ // A conversation started from a phone is the phone's: the room keeps the one it had open.
98
+ if (activate) index.active = id;
83
99
  await mkdir(join(roomDir, 'chats', id), { recursive: true });
84
100
  await writeIndex(roomDir, index);
85
101
  return shape(id, index.chats[id], index.active);
86
102
  }
87
103
 
88
- export async function openChat(roomDir, id) {
104
+ export function openChat(roomDir, ...rest) {
105
+ return indexTurn(roomDir, () => openChatNow(roomDir, ...rest));
106
+ }
107
+ async function openChatNow(roomDir, id) {
89
108
  const index = await chatIndex(roomDir);
90
109
  if (!index.chats[id]) return null;
91
110
  index.active = id;
@@ -93,7 +112,10 @@ export async function openChat(roomDir, id) {
93
112
  return shape(id, index.chats[id], index.active);
94
113
  }
95
114
 
96
- export async function renameChat(roomDir, id, title) {
115
+ export function renameChat(roomDir, ...rest) {
116
+ return indexTurn(roomDir, () => renameChatNow(roomDir, ...rest));
117
+ }
118
+ async function renameChatNow(roomDir, id, title) {
97
119
  const index = await chatIndex(roomDir);
98
120
  if (!index.chats[id]) return null;
99
121
  const named = String(title ?? '').trim().slice(0, CHAT_TITLE_MAX);
@@ -105,7 +127,10 @@ export async function renameChat(roomDir, id, title) {
105
127
  // Deleting a conversation takes its transcript and nothing else: whatever the archivist distilled
106
128
  // from it is the project's memory, not the conversation's, and it stays. The last conversation
107
129
  // cannot be deleted — a room always has somewhere to talk.
108
- export async function deleteChat(roomDir, id) {
130
+ export function deleteChat(roomDir, ...rest) {
131
+ return indexTurn(roomDir, () => deleteChatNow(roomDir, ...rest));
132
+ }
133
+ async function deleteChatNow(roomDir, id) {
109
134
  const index = await chatIndex(roomDir);
110
135
  if (!index.chats[id]) return null;
111
136
  if (Object.keys(index.chats).length < 2) return { error: 'A room always has one conversation. Start another before deleting this one.' };
@@ -120,7 +145,10 @@ export async function deleteChat(roomDir, id) {
120
145
  // What the list shows about a conversation, kept as it happens rather than by reading every
121
146
  // ledger to draw a sidebar. A conversation with no name of its own takes the first thing the
122
147
  // human said in it, which is what they will look for.
123
- export async function touchChat(roomDir, id, { text = null, now = new Date().toISOString(), counts = true } = {}) {
148
+ export function touchChat(roomDir, ...rest) {
149
+ return indexTurn(roomDir, () => touchChatNow(roomDir, ...rest));
150
+ }
151
+ async function touchChatNow(roomDir, id, { text = null, now = new Date().toISOString(), counts = true } = {}) {
124
152
  const index = await chatIndex(roomDir);
125
153
  const entry = index.chats[id];
126
154
  if (!entry) return null;
@@ -139,7 +167,10 @@ export async function touchChat(roomDir, id, { text = null, now = new Date().toI
139
167
 
140
168
  // Conversations whose folder is on disk but which no index knows about: a room copied by hand, or
141
169
  // an index lost. They are listed rather than ignored, because a transcript is not disposable.
142
- export async function adoptStrays(roomDir) {
170
+ export function adoptStrays(roomDir, ...rest) {
171
+ return indexTurn(roomDir, () => adoptStraysNow(roomDir, ...rest));
172
+ }
173
+ async function adoptStraysNow(roomDir) {
143
174
  let folders = [];
144
175
  try { folders = await readdir(join(roomDir, 'chats')); } catch { return { adopted: [] }; }
145
176
  const index = await chatIndex(roomDir);
@@ -163,17 +194,23 @@ export async function lastSequenceOf(file, { window = 65536 } = {}) {
163
194
  const size = (await stat(file)).size;
164
195
  if (!size) return 0;
165
196
  handle = await open(file, 'r');
166
- const length = Math.min(window, size);
167
- const buffer = Buffer.alloc(length);
168
- await handle.read(buffer, 0, length, size - length);
169
- const lines = buffer.toString('utf8').split('\n').filter(Boolean);
170
- for (let i = lines.length - 1; i >= 0; i -= 1) {
171
- try {
172
- const event = JSON.parse(lines[i]);
173
- if (Number.isInteger(event?.sequence)) return event.sequence;
174
- } catch { /* a half line at the window's edge, or a torn write: keep looking back */ }
197
+ // The window doubles until it finds a whole event or holds the whole file (LOG-09 of the 1.0
198
+ // review): one window only, a last line longer than it — a long answer — read as 0, and the
199
+ // conversation opened on numbers another one had already used, which the archive then dropped.
200
+ for (let length = Math.min(window, size); ; length = Math.min(length * 2, size)) {
201
+ const buffer = Buffer.alloc(length);
202
+ await handle.read(buffer, 0, length, size - length);
203
+ const lines = buffer.toString('utf8').split('\n').filter(Boolean);
204
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
205
+ // The first line of a window that does not start the file may be cut: it is read again whole.
206
+ if (i === 0 && length < size) break;
207
+ try {
208
+ const event = JSON.parse(lines[i]);
209
+ if (Number.isInteger(event?.sequence)) return event.sequence;
210
+ } catch { /* a torn write: keep looking back */ }
211
+ }
212
+ if (length >= size) return 0;
175
213
  }
176
- return 0;
177
214
  } catch {
178
215
  return 0;
179
216
  } finally {
@@ -1,7 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { access, mkdir, mkdtemp, readdir, rm, unlink } from 'node:fs/promises';
2
+ import { access, mkdir, mkdtemp, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
- import { join, resolve, sep } from 'node:path';
4
+ import { dirname, join, resolve, sep } from 'node:path';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { promisify } from 'node:util';
7
7
 
@@ -17,12 +17,14 @@ export const CHECKPOINT_REF = 'refs/madre/checkpoints';
17
17
 
18
18
  // Paths no agent touches even in CONTROL. Enforced after the turn: whatever
19
19
  // changed here is restored from the checkpoint and reported.
20
+ // Read without regard to case (2026-10-12, SRV-05): on a Mac the disk does not tell `.ENV` from
21
+ // `.env`, so `.ENV` was the same file under a name no rule caught.
20
22
  export const FORBIDDEN = [
21
- /^\.git(\/|$)/,
22
- /^\.pulse(\/|$)/,
23
- /(^|\/)\.env(\.|$)/,
24
- /(^|\/)\.madre(\/|$)/,
25
- /(^|\/)\.claude\/settings\.local\.json$/,
23
+ /^\.git(\/|$)/i,
24
+ /^\.pulse(\/|$)/i,
25
+ /(^|\/)\.env(\.|$)/i,
26
+ /(^|\/)\.madre(\/|$)/i,
27
+ /(^|\/)\.claude\/settings\.local\.json$/i,
26
28
  ];
27
29
  export const isForbidden = (path) => FORBIDDEN.some((pattern) => pattern.test(path));
28
30
 
@@ -45,36 +47,94 @@ async function shadowFor(root) {
45
47
  }
46
48
  return shadows.get(canonical);
47
49
  }
48
- async function gitArgs(root) {
49
- if (await isGitRepo(root)) return [];
50
- return ['--git-dir', await shadowFor(root), '--work-tree', root];
50
+ // Which repository a photograph lives in: null for the project's own, or the shadow's path. A
51
+ // checkpoint keeps the answer it was taken with. Asked again after the turn, the answer changes the
52
+ // moment an agent runs `git init` in a project that had none: the diff then looked for the
53
+ // shadow's commits in the brand-new repository, git failed, and a turn that had done exactly what
54
+ // it was asked was reported as failed, with no list of changes and no UNDO.
55
+ async function repositoryFor(root) {
56
+ return (await isGitRepo(root)) ? null : shadowFor(root);
57
+ }
58
+ async function gitArgs(root, via) {
59
+ const dir = via === undefined ? await repositoryFor(root) : via;
60
+ return dir ? ['--git-dir', dir, '--work-tree', root] : [];
51
61
  }
52
62
 
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 });
63
+ // How MADRE runs git in a project (2026-10-12, CKP-01 of the 1.0 review): never running anything the
64
+ // repository's own settings name. `.git` stays writable while an agent works — the CLIs need it —
65
+ // and git status never shows what changes inside it, so a `core.fsmonitor` written into
66
+ // `.git/config` during a #3 turn ran, with MADRE's rights and outside the agent's sandbox, the
67
+ // next time MADRE photographed the project. Hooks, external diffs and text conversions are named
68
+ // the same way; none of them is needed to take or compare a photograph.
69
+ const SAFE = ['-c', 'core.quotepath=off', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'diff.external='];
70
+ async function git(root, args, { env = {}, via } = {}) {
71
+ const { stdout } = await execFileAsync('git', [...SAFE, ...(await gitArgs(root, via)), ...args], { cwd: root, env: { ...process.env, GIT_PAGER: 'cat', ...env }, maxBuffer: 16 * 1024 * 1024 });
55
72
  return stdout;
56
73
  }
57
74
 
75
+ // What a repository runs on its own: its settings, its hooks and its attributes (which name the
76
+ // filters the settings define). Photographed before a turn that may write, put back after it before
77
+ // git runs again — a write into `.git` is a write into a forbidden zone like any other.
78
+ const CONTROLS = ['config', 'info/attributes'];
79
+ async function gitDirOf(root, via) {
80
+ if (via) return via;
81
+ const dotgit = join(root, '.git');
82
+ const info = await stat(dotgit).catch(() => null);
83
+ if (info?.isDirectory()) return dotgit;
84
+ if (info?.isFile()) {
85
+ const pointer = (await readFile(dotgit, 'utf8').catch(() => '')).match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
86
+ return pointer ? resolve(root, pointer) : null;
87
+ }
88
+ return null;
89
+ }
90
+ async function photographControls(root, via, label) {
91
+ const gitDir = await gitDirOf(root, via);
92
+ if (!gitDir) return null;
93
+ const files = {};
94
+ for (const name of CONTROLS) files[name] = await readFile(join(gitDir, name)).catch(() => null);
95
+ for (const name of await readdir(join(gitDir, 'hooks')).catch(() => [])) files[`hooks/${name}`] = await readFile(join(gitDir, 'hooks', name)).catch(() => null);
96
+ return { gitDir, label, files };
97
+ }
98
+ // Puts back what changed and answers what it put back, as the project would name it.
99
+ async function restoreControls(shot) {
100
+ if (!shot) return [];
101
+ const back = [];
102
+ const now = {};
103
+ for (const name of CONTROLS) now[name] = await readFile(join(shot.gitDir, name)).catch(() => null);
104
+ for (const name of await readdir(join(shot.gitDir, 'hooks')).catch(() => [])) now[`hooks/${name}`] = await readFile(join(shot.gitDir, 'hooks', name)).catch(() => null);
105
+ for (const name of new Set([...Object.keys(shot.files), ...Object.keys(now)])) {
106
+ const was = shot.files[name] ?? null;
107
+ const is = now[name] ?? null;
108
+ if (was === null && is === null) continue;
109
+ if (was && is && was.equals(is)) continue;
110
+ const path = join(shot.gitDir, name);
111
+ if (was === null) await rm(path, { force: true, recursive: true }).catch(() => {});
112
+ else { await mkdir(dirname(path), { recursive: true }); await writeFile(path, was); }
113
+ back.push(`${shot.label}${name}`);
114
+ }
115
+ return back;
116
+ }
117
+
58
118
  // A tree object of the working tree as it is right now: tracked and untracked
59
119
  // files, ignored ones left out. Built in a temporary index.
60
- export async function worktreeTree(root, { exclude = [] } = {}) {
120
+ export async function worktreeTree(root, { exclude = [], via } = {}) {
61
121
  const dir = await mkdtemp(join(tmpdir(), 'madre-index-'));
62
122
  const index = join(dir, 'index');
63
123
  try {
64
124
  const env = { GIT_INDEX_FILE: index };
65
- const extra = await gitArgs(root);
66
- await git(root, ['read-tree', '--empty'], { env });
125
+ const extra = await gitArgs(root, via);
126
+ await git(root, ['read-tree', '--empty'], { env, via });
67
127
  // Every file of the working tree that git would not ignore, listed against the empty
68
128
  // index. Nested repositories come back as `dir/` entries and are left out: they are
69
129
  // 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 });
130
+ const listed = await git(root, ['ls-files', '-z', '--others', '--exclude-standard', '--', '.'], { env, via });
71
131
  const skip = new Set(exclude.map((path) => path.replace(/\/$/, '')));
72
132
  // Lock folders (MADRE's own `*.lock/`) come and go between the listing and the indexing.
73
133
  const files = listed.split('\0').filter((path) => path && !path.endsWith('/') && !/(^|\/)[^/]+\.lock\//.test(path) && ![...skip].some((dir) => path === dir || path.startsWith(`${dir}/`)));
74
134
  if (files.length) {
75
135
  // --remove: a file that vanished since the listing is dropped instead of aborting the photograph.
76
136
  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()));
137
+ const child = execFile('git', [...SAFE, ...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
138
  child.stdin.end(list.join('\0') + '\0');
79
139
  });
80
140
  try { await index_(files); } catch {
@@ -84,7 +144,7 @@ export async function worktreeTree(root, { exclude = [] } = {}) {
84
144
  if (alive.length) await index_(alive);
85
145
  }
86
146
  }
87
- return (await git(root, ['write-tree'], { env })).trim();
147
+ return (await git(root, ['write-tree'], { env, via })).trim();
88
148
  } finally {
89
149
  await rm(dir, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 });
90
150
  }
@@ -112,11 +172,12 @@ export async function nestedRepos(root, { maxDepth = 3 } = {}) {
112
172
  }
113
173
 
114
174
  async function checkpointOne(root, { id, label, exclude = [] }) {
115
- const tree = await worktreeTree(root, { exclude });
116
- const head = (await git(root, ['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')).trim() || null;
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();
118
- await git(root, ['update-ref', `${CHECKPOINT_REF}/${id}`, commit]);
119
- return { commit, tree, head };
175
+ const via = await repositoryFor(root);
176
+ const tree = await worktreeTree(root, { exclude, via });
177
+ const head = (await git(root, ['rev-parse', '--verify', '-q', 'HEAD'], { via }).catch(() => '')).trim() || null;
178
+ const commit = (await git(root, ['commit-tree', tree, ...(head ? ['-p', head] : []), '-m', `${label} ${id}`], { via, env: { GIT_AUTHOR_NAME: 'MADRE', GIT_AUTHOR_EMAIL: 'madre@localhost', GIT_COMMITTER_NAME: 'MADRE', GIT_COMMITTER_EMAIL: 'madre@localhost' } })).trim();
179
+ await git(root, ['update-ref', `${CHECKPOINT_REF}/${id}`, commit], { via });
180
+ return { commit, tree, head, via };
120
181
  }
121
182
 
122
183
  export async function createCheckpoint(root, { id, label = 'MADRE checkpoint' } = {}) {
@@ -126,20 +187,43 @@ export async function createCheckpoint(root, { id, label = 'MADRE checkpoint' }
126
187
  for (const dir of dirs) {
127
188
  try { nested.push({ dir, ...(await checkpointOne(join(root, dir), { id, label })) }); } catch { /* a broken nested repo is left alone */ }
128
189
  }
129
- return { id, ...outer, nested, exclude: dirs, createdAt: new Date().toISOString() };
190
+ const controls = [await photographControls(root, outer.via, outer.via ? 'MADRE · ' : '.git/')];
191
+ for (const part of nested) controls.push(await photographControls(join(root, part.dir), part.via, `${part.dir}/.git/`));
192
+ return { id, ...outer, nested, exclude: dirs, controls: controls.filter(Boolean), createdAt: new Date().toISOString() };
193
+ }
194
+
195
+ // After a turn that could write, before git runs again: the repositories' own settings, hooks and
196
+ // attributes as they were. Once per checkpoint; answers what was put back.
197
+ export async function settleControls(checkpoint) {
198
+ if (!checkpoint || checkpoint.controlsSettled) return [];
199
+ checkpoint.controlsSettled = true;
200
+ const back = [];
201
+ for (const shot of checkpoint.controls ?? []) back.push(...await restoreControls(shot));
202
+ return back;
130
203
  }
131
204
 
132
205
  // What changed since the checkpoint: name-status per file and git's --stat.
133
206
  async function diffOne(root, before, prefix = '', exclude = []) {
134
- const after = await worktreeTree(root, { exclude });
207
+ const via = before.via;
208
+ const after = await worktreeTree(root, { exclude, via });
135
209
  if (after === before.tree) return { files: [], stat: '' };
136
- const nameStatus = await git(root, ['diff', '--name-status', '-M', before.tree, after]);
137
- const files = nameStatus.split('\n').filter(Boolean).map((line) => {
210
+ const nameStatus = await git(root, ['diff', '--no-ext-diff', '--no-textconv', '--name-status', '-M', before.tree, after], { via });
211
+ const listed = nameStatus.split('\n').filter(Boolean).map((line) => {
138
212
  const [status, ...rest] = line.split('\t');
139
213
  const path = prefix + rest.at(-1);
140
- return { status: status[0], path, from: status[0] === 'R' ? prefix + rest[0] : undefined };
214
+ return { status: status[0], path, inner: rest.at(-1), from: status[0] === 'R' ? prefix + rest[0] : undefined };
141
215
  });
142
- const stat = (await git(root, ['diff', '--stat=100', before.tree, after])).trim();
216
+ // A file the turn taught git to ignore (a new .gitignore line) drops out of the second
217
+ // photograph while it is still on disk, untouched. That is not a deletion, and restoring it
218
+ // would report a write into a forbidden zone that never happened.
219
+ const files = [];
220
+ for (const file of listed) {
221
+ if (file.status === 'D' && await access(join(root, file.inner)).then(() => true, () => false)) continue;
222
+ files.push({ status: file.status, path: file.path, from: file.from });
223
+ }
224
+ if (!files.length) return { files: [], stat: '' };
225
+ const only = files.length < listed.length ? ['--', ...files.flatMap((file) => [file.path.slice(prefix.length), ...(file.from ? [file.from.slice(prefix.length)] : [])])] : [];
226
+ const stat = (await git(root, ['diff', '--no-ext-diff', '--no-textconv', '--stat=100', before.tree, after, ...only], { via })).trim();
143
227
  return { files, stat: prefix && stat ? stat.split('\n').map((line) => (line.startsWith(' ') ? ` ${prefix}${line.trimStart()}` : line)).join('\n') : stat };
144
228
  }
145
229
 
@@ -160,9 +244,9 @@ export async function diffCheckpoint(root, checkpoint) {
160
244
  // Which repository a project-relative path belongs to, and the path inside it.
161
245
  function repoFor(root, checkpoint, path) {
162
246
  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) };
247
+ if (path.startsWith(`${part.dir}/`)) return { dir: join(root, part.dir), commit: part.commit, via: part.via, inner: path.slice(part.dir.length + 1) };
164
248
  }
165
- return { dir: root, commit: checkpoint.commit, inner: path };
249
+ return { dir: root, commit: checkpoint.commit, via: checkpoint.via, inner: path };
166
250
  }
167
251
 
168
252
  // Put the working tree back to the checkpoint: files added since are removed,
@@ -185,19 +269,37 @@ export async function restoreCheckpoint(root, checkpoint, { paths = null } = {})
185
269
  if (file.status === 'R' && file.from) {
186
270
  await unlink(absolute).catch(() => {});
187
271
  const origin = repoFor(root, checkpoint, file.from);
188
- await git(origin.dir, ['restore', '--source', origin.commit, '--worktree', '--', origin.inner]);
272
+ await git(origin.dir, ['restore', '--source', origin.commit, '--worktree', '--', origin.inner], { via: origin.via });
189
273
  restored.push(file.from);
190
274
  removed.push(file.path);
191
275
  continue;
192
276
  }
193
- await git(repo.dir, ['restore', '--source', repo.commit, '--worktree', '--', repo.inner]);
277
+ await git(repo.dir, ['restore', '--source', repo.commit, '--worktree', '--', repo.inner], { via: repo.via });
194
278
  restored.push(file.path);
195
279
  }
196
280
  }
281
+ await dropFoldersBornAfter(root, removed, Date.parse(checkpoint.createdAt));
197
282
  return { removed, restored };
198
283
  }
199
284
 
285
+ // A folder the turn made for the files it added goes with them. Git keeps no folders, so after
286
+ // UNDO an empty docs/ was the one trace left of a turn taken back. Only a folder born after the
287
+ // checkpoint, deepest first, and only while it is empty: rmdir refuses anything else. Where the
288
+ // system does not say when a folder was born, it stays.
289
+ async function dropFoldersBornAfter(root, paths, since) {
290
+ if (!Number.isFinite(since) || !paths.length) return;
291
+ const canonicalRoot = resolve(root);
292
+ const folders = new Set();
293
+ for (const path of paths) {
294
+ for (let dir = dirname(resolve(root, path)); dir.startsWith(canonicalRoot + sep); dir = dirname(dir)) folders.add(dir);
295
+ }
296
+ for (const dir of [...folders].sort((a, b) => b.length - a.length)) {
297
+ const born = (await stat(dir).catch(() => null))?.birthtimeMs;
298
+ if (born > 0 && born >= since) await rmdir(dir).catch(() => {});
299
+ }
300
+ }
301
+
200
302
  export async function dropCheckpoint(root, checkpoint) {
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(() => {});
303
+ await git(root, ['update-ref', '-d', `${CHECKPOINT_REF}/${checkpoint.id}`], { via: checkpoint.via }).catch(() => {});
304
+ for (const part of checkpoint.nested ?? []) await git(join(root, part.dir), ['update-ref', '-d', `${CHECKPOINT_REF}/${checkpoint.id}`], { via: part.via }).catch(() => {});
203
305
  }
package/src/choices.mjs CHANGED
@@ -35,6 +35,10 @@ const MAX_OPTION = 320;
35
35
 
36
36
  const RECOMMENDED = /\s*[(\[]\s*(?:recommended|recomendad[ao]|sugerid[ao])\s*[)\]]\s*$/i;
37
37
  const STAR = /^[★☆⭐]\s*/;
38
+ // The mode an option needs, written by the agent at its end: (#3), [#2 CREATE], #4. Below #1
39
+ // nothing needs saying; an option that changes the project says which permission it will take,
40
+ // so the human sees it before pressing rather than after the agent refuses.
41
+ const MODE = /\s*[(\[]?\s*#([0-4])(?:\s+[A-Za-z]+)?\s*[)\]]?\s*$/;
38
42
 
39
43
  function clip(text, max) {
40
44
  if (text.length <= max) return text;
@@ -58,12 +62,21 @@ export function parseChoice(text) {
58
62
  const options = [];
59
63
  const seen = new Set();
60
64
  let recommended = null;
65
+ const modes = [];
61
66
  for (const line of lines.slice(question ? 1 : 0)) {
62
67
  const match = line.match(/^[-*•]\s+(.+)$/);
63
68
  if (!match) continue;
64
69
  let label = match[1].trim();
65
- const marked = RECOMMENDED.test(label) || STAR.test(label);
66
- label = clip(label.replace(RECOMMENDED, '').replace(STAR, '').trim(), MAX_OPTION);
70
+ // The two marks come in either order: «… (#3) (recommended)» or «… (recommended) #3».
71
+ let marked = STAR.test(label);
72
+ let mode = null;
73
+ label = label.replace(STAR, '');
74
+ for (let pass = 0; pass < 2; pass += 1) {
75
+ if (RECOMMENDED.test(label)) { marked = true; label = label.replace(RECOMMENDED, ''); }
76
+ const found = label.match(MODE);
77
+ if (found && mode === null) { mode = Number(found[1]); label = label.replace(MODE, ''); }
78
+ }
79
+ label = clip(label.trim(), MAX_OPTION);
67
80
  // The same option twice is a mistake, and a choice that offers it twice reads as rigged.
68
81
  const key = label.toLowerCase();
69
82
  if (!label || seen.has(key)) continue;
@@ -72,10 +85,12 @@ export function parseChoice(text) {
72
85
  // would punish the agent for the half it got right.
73
86
  if (marked && recommended === null) recommended = options.length;
74
87
  options.push(label);
88
+ modes.push(mode);
75
89
  }
76
90
  if (!question) return null;
77
91
  if (options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) return null;
78
- return { question, options, recommended };
92
+ // Modes travel only when an option has one: a choice that is all talk carries no column of nulls.
93
+ return { question, options, recommended, ...(modes.some((mode) => mode !== null) ? { modes } : {}) };
79
94
  }
80
95
 
81
96
  // What the reply reads as once the block has been lifted out of it: the room shows the question
package/src/cli-args.mjs CHANGED
@@ -6,7 +6,13 @@
6
6
  export function parseArgs(argv) {
7
7
  const args = [...argv];
8
8
  const helpFlags = new Set(['help', '--help', '-h']);
9
- const command = helpFlags.has(args[0])
9
+ // The first word is the command: `start` when there is none, or when the line opens with an
10
+ // option. `salas` (docs/MACROVERSO.md, D-S09) is one more word here; what it opens is decided
11
+ // beside the room of Salas, in src/salas.mjs.
12
+ // `named`: whether a command word was written at all. A bare `madre` (or `npx madre`) opens the
13
+ // lobby; `madre start` keeps opening the folder it is run in (src/salas.mjs, projectRootFor).
14
+ const named = Boolean(args[0]) && !args[0].startsWith('-') && !helpFlags.has(args[0]);
15
+ let command = helpFlags.has(args[0])
10
16
  ? 'help'
11
17
  : args[0]?.startsWith('-') ? 'start' : (args.shift() ?? 'start');
12
18
 
@@ -29,8 +35,13 @@ export function parseArgs(argv) {
29
35
  flags.add(name);
30
36
  }
31
37
  }
38
+ // `--help` after a command asks for the help, not for the command: `madre salas --help` prints
39
+ // what `salas` does instead of opening it. A value never starts with `-`, so `-h` on its own is
40
+ // never something an option was given.
41
+ if (flags.has('help') || args.includes('-h')) command = 'help';
32
42
  return {
33
43
  command,
44
+ named,
34
45
  option: (name, fallback) => options[name] ?? fallback,
35
46
  has: (name) => flags.has(name) || name in options,
36
47
  explicit: (name) => name in options,
package/src/commands.mjs CHANGED
@@ -24,7 +24,7 @@ export const COMMANDS = [
24
24
  {
25
25
  name: 'git',
26
26
  module: 'git-pulse',
27
- title: 'Git Pulse',
27
+ title: 'Git',
28
28
  usage: '/git [status|log|diff|branches|commit "message"|push [confirm]]',
29
29
  summary: 'Repository status, recent commits, uncommitted changes and branches from the project itself; commit and push by your own hand, with a confirmation before anything leaves.',
30
30
  async available({ projectRoot }) {
@@ -51,41 +51,41 @@ export const COMMANDS = [
51
51
  } else if (what === 'commit') {
52
52
  // The human commits what the room produced. Everything in the tree, one message, local: reversible with git.
53
53
  const message = args.slice(1).join(' ').replace(/^["'“]+|["'”]+$/g, '').trim();
54
- if (!message) return { ok: false, title: 'Git Pulse · commit', text: 'Give the commit a message: /git commit "what and why".' };
54
+ if (!message) return { ok: false, title: 'Git · commit', text: 'Give the commit a message: /git commit "what and why".' };
55
55
  // Without an identity git refuses the commit with a wall of advice; say the one thing to do.
56
56
  const email = await run('git', ['config', '--get', 'user.email'], projectRoot);
57
57
  const who = await run('git', ['config', '--get', 'user.name'], projectRoot);
58
58
  if (!email.text.trim() || !who.text.trim()) {
59
- return { ok: false, title: 'Git Pulse · commit', text: 'This computer has no git identity, so the commit would have no author. Set it once in your terminal:\n\n git config --global user.name "Your Name"\n git config --global user.email you@example.com' };
59
+ return { ok: false, title: 'Git · commit', text: 'This computer has no git identity, so the commit would have no author. Set it once in your terminal:\n\n git config --global user.name "Your Name"\n git config --global user.email you@example.com' };
60
60
  }
61
61
  const staged = await run('git', ['add', '-A', '--', '.'], projectRoot);
62
- if (!staged.ok) return { ok: false, title: 'Git Pulse · commit', text: staged.text };
62
+ if (!staged.ok) return { ok: false, title: 'Git · commit', text: staged.text };
63
63
  const committed = await run('git', ['-c', 'color.ui=never', 'commit', '-m', message], projectRoot);
64
- if (!committed.ok) return { ok: false, title: 'Git Pulse · commit', text: committed.text || 'Nothing to commit.' };
64
+ if (!committed.ok) return { ok: false, title: 'Git · commit', text: committed.text || 'Nothing to commit.' };
65
65
  await add('committed', ['-c', 'color.ui=never', 'show', '--stat', '--format=%h %s', 'HEAD']);
66
- return { ok: true, title: 'Git Pulse · commit', text: sections.join('\n\n') };
66
+ return { ok: true, title: 'Git · commit', text: sections.join('\n\n') };
67
67
  } else if (what === 'push') {
68
68
  // Nothing leaves without the word: first the preview of what would go, then /git push confirm.
69
69
  const upstream = await run('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], projectRoot);
70
- if (!upstream.ok) return { ok: false, title: 'Git Pulse · push', text: `This branch has no upstream. Set it once from your terminal: git push -u <remote> <branch>.` };
70
+ if (!upstream.ok) return { ok: false, title: 'Git · push', text: `This branch has no upstream. Set it once from your terminal: git push -u <remote> <branch>.` };
71
71
  const ahead = await run('git', ['-c', 'color.ui=never', 'log', '--oneline', '@{u}..HEAD'], projectRoot);
72
72
  const commits = ahead.text ? ahead.text.split('\n').filter(Boolean) : [];
73
- if (!commits.length) return { ok: true, title: 'Git Pulse · push', text: `Nothing to push: ${upstream.text} already has everything.` };
73
+ if (!commits.length) return { ok: true, title: 'Git · push', text: `Nothing to push: ${upstream.text} already has everything.` };
74
74
  if ((args[1] ?? '').toLowerCase() !== 'confirm') {
75
- return { ok: true, title: 'Git Pulse · push · preview', text: `## would leave for ${upstream.text}\n${commits.join('\n')}\n\nThis leaves the machine and cannot be undone by MADRE. Send it with: /git push confirm` };
75
+ return { ok: true, title: 'Git · push · preview', text: `## would leave for ${upstream.text}\n${commits.join('\n')}\n\nThis leaves the machine and cannot be undone by MADRE. Send it with: /git push confirm` };
76
76
  }
77
77
  const pushed = await run('git', ['-c', 'color.ui=never', 'push'], projectRoot, 120000);
78
- return { ok: pushed.ok, title: 'Git Pulse · push', text: `## sent to ${upstream.text}\n${commits.join('\n')}\n\n${pushed.text || '(pushed)'}` };
78
+ return { ok: pushed.ok, title: 'Git · push', text: `## sent to ${upstream.text}\n${commits.join('\n')}\n\n${pushed.text || '(pushed)'}` };
79
79
  } else {
80
- return { ok: false, title: 'Git Pulse', text: `Unknown subcommand "${what}". Use /git status, /git log [n], /git diff, /git branches, /git commit "message" or /git push [confirm].` };
80
+ return { ok: false, title: 'Git', text: `Unknown subcommand "${what}". Use /git status, /git log [n], /git diff, /git branches, /git commit "message" or /git push [confirm].` };
81
81
  }
82
- return { ok: true, title: `Git Pulse · ${what}`, text: sections.join('\n\n') };
82
+ return { ok: true, title: `Git · ${what}`, text: sections.join('\n\n') };
83
83
  },
84
84
  },
85
85
  {
86
86
  name: 'ahp',
87
87
  module: 'ahp',
88
- title: 'AHP+',
88
+ title: 'Continuity',
89
89
  usage: '/ahp [status|check|context]',
90
90
  summary: 'Ask the AHP+ installed in this project for its verified status, checks or session context.',
91
91
  async available({ projectRoot }) {
@@ -98,9 +98,9 @@ export const COMMANDS = [
98
98
  check: ['project', 'check', '.'],
99
99
  context: ['session', 'context', '.', '--format', 'markdown', '--budget', '4000'],
100
100
  };
101
- if (!map[what]) return { ok: false, title: 'AHP+', text: `Unknown subcommand "${what}". Use /ahp status, /ahp check or /ahp context.` };
101
+ if (!map[what]) return { ok: false, title: 'Continuity', text: `Unknown subcommand "${what}". Use /ahp status, /ahp check or /ahp context.` };
102
102
  const result = await run(join(projectRoot, 'node_modules', '.bin', 'ahp'), map[what], projectRoot, 60000);
103
- return { ok: result.ok, title: `AHP+ · ${what}`, text: result.text || '(no output)' };
103
+ return { ok: result.ok, title: `Continuity · ${what}`, text: result.text || '(no output)' };
104
104
  },
105
105
  },
106
106
  ];