@jossuealcala/madre 1.0.1 → 1.0.3

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 (189) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +2013 -0
  3. package/CHANGELOG.md +102 -1
  4. package/CONTRIBUTING.md +1 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +26 -14
  7. package/README.en.md +241 -0
  8. package/README.md +21 -12
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +33 -16
  11. package/bin/madre.cjs +40 -0
  12. package/bin/madre.mjs +7 -0
  13. package/docs/INTERNALS.md +6 -0
  14. package/docs/REFERENCE.md +40 -5
  15. package/docs/SDK.md +4 -2
  16. package/docs/en/INTERNALS.md +103 -0
  17. package/docs/en/REFERENCE.md +365 -0
  18. package/docs/en/SDK.md +308 -0
  19. package/docs/en/training/README.md +59 -0
  20. package/docs/training/README.md +48 -9
  21. package/docs/training/train.sh +7 -2
  22. package/package.json +13 -5
  23. package/public/abduction.js +2 -0
  24. package/public/app.js +3154 -539
  25. package/public/attribution.js +114 -0
  26. package/public/brands.js +23 -6
  27. package/public/code-tools.js +143 -0
  28. package/public/editor.js +22 -4
  29. package/public/es.js +1004 -416
  30. package/public/hardware.js +50 -0
  31. package/public/i18n.js +25 -1
  32. package/public/index.html +18 -11
  33. package/public/inquiry.js +2 -0
  34. package/public/loader.js +2 -0
  35. package/public/macroverso.js +189 -96
  36. package/public/market-art.js +161 -0
  37. package/public/market-view.js +529 -0
  38. package/public/market.js +189 -0
  39. package/public/neural.js +110 -0
  40. package/public/nostromo-engine.js +414 -47
  41. package/public/power.js +100 -0
  42. package/public/resay.js +11 -0
  43. package/public/review-ask.js +2 -0
  44. package/public/routing.js +23 -1
  45. package/public/salas-mac.css +119 -18
  46. package/public/salas-mac.html +14 -2
  47. package/public/salas-mac.js +358 -56
  48. package/public/salas.css +2 -0
  49. package/public/salas.html +1 -0
  50. package/public/salas.js +4 -2
  51. package/public/styles.css +1793 -77
  52. package/public/syntax.js +2 -0
  53. package/public/touch.js +2 -0
  54. package/public/tour-chapters.js +2 -0
  55. package/public/tour.js +84 -5
  56. package/public/troubleshooting.js +24 -17
  57. package/public/wallpaper.js +275 -0
  58. package/src/adapters/claude.mjs +2 -0
  59. package/src/adapters/codex.mjs +2 -0
  60. package/src/adapters/cursor.mjs +96 -0
  61. package/src/adapters/gemini.mjs +13 -4
  62. package/src/adapters/madre.mjs +5 -2
  63. package/src/adapters/opencode.mjs +7 -3
  64. package/src/adapters/process.mjs +2 -0
  65. package/src/asking.mjs +2 -0
  66. package/src/auth-probe.mjs +145 -12
  67. package/src/bridge/devices.mjs +2 -0
  68. package/src/bridge/handoff.mjs +2 -0
  69. package/src/bridge/host.mjs +2 -0
  70. package/src/bridge/network.mjs +2 -0
  71. package/src/bridge/policy.mjs +3 -1
  72. package/src/bridge/qr.mjs +2 -0
  73. package/src/bridge/server.mjs +2 -0
  74. package/src/capabilities.mjs +11 -0
  75. package/src/chats.mjs +2 -0
  76. package/src/checkpoint.mjs +18 -1
  77. package/src/choices.mjs +2 -0
  78. package/src/cli-args.mjs +2 -0
  79. package/src/cold.mjs +2 -0
  80. package/src/commands.mjs +2 -0
  81. package/src/config.mjs +2 -0
  82. package/src/conversation-context.mjs +15 -1
  83. package/src/credentials.mjs +35 -3
  84. package/src/dataset.mjs +78 -4
  85. package/src/directives.mjs +2 -0
  86. package/src/distiller.mjs +2 -0
  87. package/src/embeddings.mjs +2 -0
  88. package/src/enlaces.mjs +214 -6
  89. package/src/event-store.mjs +2 -0
  90. package/src/exam.mjs +6 -4
  91. package/src/extensions.mjs +13 -3
  92. package/src/eyecat-watch.mjs +2 -0
  93. package/src/eyecat.mjs +2 -0
  94. package/src/file-ops.mjs +2 -0
  95. package/src/files.mjs +32 -0
  96. package/src/github.mjs +2 -0
  97. package/src/heartbeat.mjs +2 -0
  98. package/src/i18n.mjs +31 -1
  99. package/src/image-studio.mjs +2 -0
  100. package/src/json-file.mjs +2 -0
  101. package/src/launch.mjs +2 -0
  102. package/src/lease.mjs +5 -1
  103. package/src/lobby-work.mjs +2 -0
  104. package/src/local-ai.mjs +2 -0
  105. package/src/looks.mjs +127 -2
  106. package/src/market-key.mjs +8 -0
  107. package/src/market-remote.mjs +239 -0
  108. package/src/maturity.mjs +3 -1
  109. package/src/mcp/connect.mjs +345 -0
  110. package/src/mcp/github-server.mjs +2 -0
  111. package/src/mcp/image-server.mjs +2 -0
  112. package/src/mcp/memory-server.mjs +2 -0
  113. package/src/mcp/proxy.mjs +153 -0
  114. package/src/mcp/smtp-server.mjs +2 -0
  115. package/src/memory-dashboard.mjs +2 -0
  116. package/src/memory-tools.mjs +2 -0
  117. package/src/memory.mjs +2 -0
  118. package/src/models.mjs +49 -3
  119. package/src/module-scope.mjs +68 -0
  120. package/src/modules/ahp.mjs +104 -7
  121. package/src/modules/ash-policy.mjs +2 -0
  122. package/src/modules/ash.mjs +3 -1
  123. package/src/modules/bishop-exam.mjs +122 -0
  124. package/src/modules/bishop-plan.mjs +5 -3
  125. package/src/modules/bishop.mjs +84 -23
  126. package/src/modules/choices.mjs +2 -0
  127. package/src/modules/correo.mjs +2 -0
  128. package/src/modules/enlaces.mjs +89 -6
  129. package/src/modules/git-pulse.mjs +2 -0
  130. package/src/modules/github.mjs +2 -0
  131. package/src/modules/helpers.mjs +2 -0
  132. package/src/modules/image-studio.mjs +13 -2
  133. package/src/modules/index.mjs +4 -1
  134. package/src/modules/intents-calibration.mjs +2 -0
  135. package/src/modules/intents-desk.mjs +4 -2
  136. package/src/modules/intents-observe.mjs +2 -0
  137. package/src/modules/intents-policy.mjs +4 -1
  138. package/src/modules/intents-try.mjs +2 -0
  139. package/src/modules/intents.mjs +11 -7
  140. package/src/modules/local-models.mjs +124 -0
  141. package/src/modules/mcp-connect.mjs +293 -0
  142. package/src/modules/ollama.mjs +15 -4
  143. package/src/modules/playwright.mjs +88 -15
  144. package/src/modules/ripley.mjs +2 -0
  145. package/src/modules/runs.mjs +5 -3
  146. package/src/modules/sdk.mjs +63 -9
  147. package/src/modules/updates.mjs +2 -0
  148. package/src/mother.mjs +2 -0
  149. package/src/npm-launch.mjs +18 -0
  150. package/src/ollama.mjs +8 -5
  151. package/src/outbound.mjs +16 -6
  152. package/src/override.mjs +2 -0
  153. package/src/ports.mjs +6 -2
  154. package/src/prices.mjs +2 -0
  155. package/src/privacy.mjs +2 -0
  156. package/src/quota-monitor.mjs +2 -0
  157. package/src/quota-sources.mjs +2 -0
  158. package/src/request-guard.mjs +2 -0
  159. package/src/review-ask.mjs +2 -0
  160. package/src/room/archivist.mjs +2 -0
  161. package/src/room/attachments.mjs +2 -0
  162. package/src/room/budget.mjs +2 -0
  163. package/src/room/context.mjs +2 -0
  164. package/src/room/control.mjs +7 -1
  165. package/src/room/copilot.mjs +76 -0
  166. package/src/room/economy.mjs +2 -0
  167. package/src/room/escalation.mjs +2 -0
  168. package/src/room/ghost.mjs +2 -0
  169. package/src/room/guard.mjs +2 -0
  170. package/src/room/prompt.mjs +25 -5
  171. package/src/room/returns.mjs +82 -0
  172. package/src/room/runs.mjs +4 -0
  173. package/src/room/vectors.mjs +2 -0
  174. package/src/room.mjs +223 -35
  175. package/src/router.mjs +3 -1
  176. package/src/runs.mjs +6 -1
  177. package/src/runtime-detection.mjs +11 -0
  178. package/src/salas.mjs +28 -0
  179. package/src/sentinel-errors.mjs +2 -0
  180. package/src/server-sentinel.mjs +2 -0
  181. package/src/server.mjs +757 -81
  182. package/src/setup.mjs +3 -1
  183. package/src/smtp.mjs +2 -0
  184. package/src/tour.mjs +2 -0
  185. package/src/updates.mjs +2 -0
  186. package/src/usage-sentinel.mjs +2 -0
  187. package/src/vault.mjs +2 -0
  188. package/src/verdict.mjs +3 -1
  189. package/src/whats-new.mjs +2 -0
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
+ // Cursor's agent CLI (`cursor-agent`, 2026.05), added 2026-10-17. It reads only: every turn runs in
4
+ // Cursor's own ask mode (`--mode ask`, «Q&A style, read-only») under a configuration made for the
5
+ // turn and removed after it (CURSOR_CONFIG_DIR), which denies every command and every write and
6
+ // keeps the sandbox off. Print mode alone would give it every tool, write and shell included, so
7
+ // neither is ever left out. Its sign-in lives in the system keychain, not in that folder, so the
8
+ // turn keeps the person's session and nothing else of their settings.
9
+ //
10
+ // Tried against the real CLI on 2026-10-17: it read and answered; asked to create a file and to run
11
+ // `touch`, it wrote nothing and said it was in Ask mode. `--sandbox enabled` was dropped: on a Mac
12
+ // where Cursor's sandbox is not available the run ends at once («Sandbox mode is enabled but not
13
+ // available on this system»). Writing (#2 and up) waits for its own trial (capabilities.mjs).
14
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { runReadonlyProcess } from './process.mjs';
18
+
19
+ // What the turn's own configuration allows: reading, and nothing else.
20
+ export const READONLY_CONFIG = {
21
+ version: 1,
22
+ permissions: { allow: [], deny: ['Shell(*)', 'Write(**)'] },
23
+ approvalMode: 'allowlist',
24
+ sandbox: { mode: 'disabled' },
25
+ };
26
+
27
+ export function buildCursorArgs({ projectRoot, prompt, model = process.env.PULSE_CURSOR_MODEL || null }) {
28
+ return [
29
+ '--print',
30
+ '--output-format', 'stream-json',
31
+ '--mode', 'ask',
32
+ // A headless run cannot answer the «trust this folder?» question; the project is the human's.
33
+ '--trust',
34
+ '--workspace', projectRoot,
35
+ ...(model && model !== 'auto' ? ['--model', model] : []),
36
+ prompt,
37
+ ];
38
+ }
39
+
40
+ export function cursorEnvironment(environment = process.env, { configDir = null } = {}) {
41
+ // A turn never opens a browser: a sign-in is the room's to start, from Settings › Agents.
42
+ return { ...environment, NO_OPEN_BROWSER: '1', ...(configDir ? { CURSOR_CONFIG_DIR: configDir } : {}) };
43
+ }
44
+
45
+ // `--output-format stream-json` writes one JSON object per line and closes with a `result` line
46
+ // that carries the whole answer and, when the CLI has it, the turn's tokens. The assistant lines
47
+ // before it are the same text in pieces: read when the result never came (the run was cut short).
48
+ export function parseCursorOutput(output) {
49
+ const pieces = [];
50
+ let result = null;
51
+ let usage = null;
52
+ let error = null;
53
+ for (const line of String(output ?? '').split('\n').filter(Boolean)) {
54
+ let event;
55
+ try { event = JSON.parse(line); } catch { continue; }
56
+ if (event?.type === 'assistant') {
57
+ for (const part of event.message?.content ?? []) if (part?.type === 'text' && typeof part.text === 'string') pieces.push(part.text);
58
+ }
59
+ if (event?.type === 'result') {
60
+ if (event.is_error) error = typeof event.result === 'string' && event.result.trim() ? event.result.trim() : 'Cursor returned an error.';
61
+ else if (typeof event.result === 'string') result = event.result;
62
+ const tokens = event.usage;
63
+ if (tokens && typeof tokens === 'object') {
64
+ const input = Number(tokens.inputTokens ?? tokens.input_tokens ?? 0);
65
+ const output = Number(tokens.outputTokens ?? tokens.output_tokens ?? 0);
66
+ const cached = Number(tokens.cacheReadTokens ?? tokens.cache_read_tokens ?? 0);
67
+ usage = { inputTokens: input, cachedInputTokens: cached, outputTokens: output, reasoningTokens: 0, totalTokens: input + output, costUsd: null, source: 'cursor-json' };
68
+ }
69
+ }
70
+ if (event?.type === 'error' && !error) error = String(event.error?.message ?? event.message ?? 'Cursor returned an error.');
71
+ }
72
+ // A run that ended in an error answers nothing, so the room reports the error, not a fragment.
73
+ const text = error ? '' : (result ?? pieces.join('')).trim();
74
+ return { text, usage, ...(error ? { error } : {}) };
75
+ }
76
+
77
+ export async function invokeCursor({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, onProgress = null }) {
78
+ const configDir = await mkdtemp(join(tmpdir(), 'madre-cursor-'));
79
+ try {
80
+ await writeFile(join(configDir, 'cli-config.json'), `${JSON.stringify(READONLY_CONFIG, null, 2)}\n`);
81
+ return await runReadonlyProcess({
82
+ onProgress: onProgress ? (out) => onProgress({ chars: out.length }) : null,
83
+ executable,
84
+ args: buildCursorArgs({ projectRoot, prompt, ...(model ? { model } : {}) }),
85
+ cwd: projectRoot,
86
+ env: cursorEnvironment(process.env, { configDir }),
87
+ timeoutMs,
88
+ signal,
89
+ label: 'Cursor',
90
+ parse: parseCursorOutput,
91
+ });
92
+ } finally {
93
+ // The chats and caches the CLI wrote there go with it: a turn keeps no session of its own.
94
+ await rm(configDir, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 }).catch(() => {});
95
+ }
96
+ }
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  import { priceTokens, PRICES_CHECKED } from '../prices.mjs';
2
4
  import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
5
  import { t } from '../i18n.mjs';
@@ -276,7 +278,14 @@ export function diagnoseGeminiStderr(stderr) {
276
278
  if (/status:?\s*503|UNAVAILABLE|high demand/i.test(text)) {
277
279
  return { code: 'UNAVAILABLE', message: t('Google reported the model as unavailable (HTTP 503) and the CLI kept retrying.'), hint: t('Try again shortly or choose another model.') };
278
280
  }
279
- if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid|IneligibleTierError/i.test(text)) {
281
+ // Google closed Gemini Code Assist for individuals to this CLI (seen 2026-10-17): the personal
282
+ // login still holds a valid token and every request is refused before it reaches a model. Named
283
+ // apart from a bad key so the room says what to do, and with Google's own word in the message so
284
+ // Diagnóstico finds its entry (gemini-ineligible-tier) in whatever language the room speaks.
285
+ if (/IneligibleTierError|no longer supported for Gemini Code Assist|migrate to the Antigravity/i.test(text)) {
286
+ return { code: 'INELIGIBLE', message: t('Google no longer accepts this Gemini sign-in (IneligibleTierError): the free personal Google account is refused before it reaches a model.'), hint: t('Paste a Gemini API key from Google AI Studio in Settings › Agents › Gemini, or run `gemini`, type /auth and pick "Use Gemini API key".') };
287
+ }
288
+ if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid/i.test(text)) {
280
289
  return { code: 'AUTH', message: t('Google rejected the Gemini credentials.'), hint: t('Run `gemini` and use /auth, or check GEMINI_API_KEY.') };
281
290
  }
282
291
  // A subagent, refused. The policy is right to refuse it — a subagent answers outside the lease
@@ -288,7 +297,7 @@ export function diagnoseGeminiStderr(stderr) {
288
297
  if (/Unauthorized tool call:\s*'?invoke_agent'?|Blocked call:[^\n]*invoke_agent/i.test(text)) {
289
298
  return {
290
299
  code: 'SUBAGENT_BLOCKED',
291
- message: t('Gemini tried to hand this turn to one of its own subagents. MADRE does not allow that: a subagent answers outside the lease, so nothing it wrote would be checked, shown or undoable.'),
300
+ message: t('Gemini tried to hand this task to one of its own subagents. MADRE does not allow that: a subagent answers outside the permission MADRE controls, so nothing it wrote would be checked, shown or undoable.'),
292
301
  hint: t('Ask again saying it should do the work itself, without subagents.'),
293
302
  };
294
303
  }
@@ -335,7 +344,7 @@ export async function invokeGemini({
335
344
  const diagnosis = diagnoseGeminiStderr(stderr);
336
345
  if (!diagnosis) return null;
337
346
  firstSeenAt ??= Date.now();
338
- const terminal = diagnosis.code === 'CREDITS_DEPLETED' || diagnosis.code === 'AUTH';
347
+ const terminal = diagnosis.code === 'CREDITS_DEPLETED' || diagnosis.code === 'AUTH' || diagnosis.code === 'INELIGIBLE';
339
348
  if (!terminal && Date.now() - firstSeenAt < failFastMs) return null;
340
349
  return Object.assign(new Error(`${diagnosis.message} ${diagnosis.hint}`), { code: diagnosis.code, diagnosis });
341
350
  };
@@ -355,7 +364,7 @@ export async function invokeGemini({
355
364
  } catch (error) {
356
365
  const produced = parseGeminiOutput(error.partialOutput ?? '').text;
357
366
  const diagnosis = error.diagnosis ?? diagnoseGeminiStderr(error.partialStderr ?? error.stderr ?? '');
358
- if (diagnosis?.code === 'AUTH' || diagnosis?.code === 'CREDITS_DEPLETED') {
367
+ if (diagnosis?.code === 'AUTH' || diagnosis?.code === 'INELIGIBLE' || diagnosis?.code === 'CREDITS_DEPLETED') {
359
368
  error.message = `${diagnosis.message} ${diagnosis.hint}`;
360
369
  error.code = diagnosis.code;
361
370
  throw error;
@@ -1,9 +1,12 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // @madre: the room's own intelligence. A local model (Ollama) that answers
2
4
  // from what the room remembers: the whole archive, not only the recent window,
3
5
  // plus the files the human points at. It never writes, never delegates, never
4
6
  // leaves the machine. It grows as the memory grows.
5
7
 
6
8
  import { ollamaGenerate } from '../ollama.mjs';
9
+ import { inLanguage } from '../i18n.mjs';
7
10
 
8
11
  export const MADRE_AGENT_ID = 'madre';
9
12
  export const MADRE_ADAPTER = 'madre-local';
@@ -230,7 +233,7 @@ export function gist(text, max = 240) {
230
233
  export function roundBullets(round, { es = true } = {}) {
231
234
  return round.answers.map((answer) => (answer.sequence
232
235
  ? `- @${answer.agent} [#${answer.sequence}]: ${gist(answer.text)}`
233
- : `- @${answer.agent}: ${es ? 'no respondió' : 'did not answer'}${answer.error ? ` (${String(answer.error).replace(/[.\s]+$/, '')})` : ''}.`)).join('\n');
236
+ : `- @${answer.agent}: ${es ? 'no respondió' : 'did not answer'}${answer.error ? ` (${gist(inLanguage(answer.error, es ? 'es' : 'en'), 200).replace(/[.\s]+$/, '')})` : ''}.`)).join('\n');
234
237
  }
235
238
  export function comparisonHolds(line, round) {
236
239
  const spoke = new Set(round.answers.filter((answer) => answer.sequence).map((answer) => answer.agent));
@@ -264,7 +267,7 @@ export async function gather(memory, text, { maxChars = 7000 } = {}) {
264
267
  export function madreInvoker({ memory, ollama, fetchImpl = globalThis.fetch }) {
265
268
  return async ({ prompt, text = '', timeoutMs = 180000, model = null, requester = 'you', crew = [], delegation = false, maxSteps = 4, round = null, bishop = null }) => {
266
269
  const state = typeof ollama === 'function' ? ollama() : ollama;
267
- if (!state?.running || !state.chatModel) throw new Error('@madre left the room: Ollama is not running. Start Ollama, or open Ollama in Modules.');
270
+ if (!state?.running || !state.chatModel) throw new Error('@madre left the project: Ollama is not running. Start Ollama, or open Local AI in Modules.');
268
271
  const question = text || prompt.match(/User message: ([\s\S]*)$/)?.[1] || prompt.slice(-2000);
269
272
  const local = localReply(question, { requester, crew, delegation, maxSteps, model: model ?? state.chatModel });
270
273
  if (local) return { text: local.text, usage: { ...NO_USAGE }, grounded: { notes: 0, quotes: 0 }, synthetic: local.kind, declined: local.kind === 'declined' ? 'action' : undefined };
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  import { runReadonlyProcess } from './process.mjs';
2
4
 
3
5
  const readonlyConfig = {
@@ -21,13 +23,15 @@ const readonlyConfig = {
21
23
  // Optional provider/model override (e.g. "openai/gpt-5.6-sol"). Without it,
22
24
  // OpenCode picks its own default provider, which may not be the one the user
23
25
  // actually signed in to.
24
- export function buildOpenCodeArgs({ projectRoot, prompt, model = process.env.PULSE_OPENCODE_MODEL, attachments = [] }) {
26
+ export function buildOpenCodeArgs({ projectRoot, prompt, model = process.env.PULSE_OPENCODE_MODEL, effort = null, attachments = [] }) {
25
27
  return [
26
28
  '--pure',
27
29
  'run',
28
30
  '--format', 'json',
29
31
  '--agent', 'pulse-readonly',
30
32
  ...(model ? ['--model', model] : []),
33
+ // The model's reasoning rung, as OpenCode names it (`--variant`, 1.18).
34
+ ...(effort ? ['--variant', effort] : []),
31
35
  ...attachments.flatMap((file) => ['--file', file.path]),
32
36
  '--dir', projectRoot,
33
37
  prompt,
@@ -154,11 +158,11 @@ export function parseOpenCodeOutput(output) {
154
158
  return { text: text.join('').trim(), usage, ...(error ? { error } : {}) };
155
159
  }
156
160
 
157
- export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [], onProgress = null }) {
161
+ export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, effort = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [], onProgress = null }) {
158
162
  return runReadonlyProcess({
159
163
  onProgress: onProgress ? (out) => onProgress({ chars: out.length }) : null,
160
164
  executable,
161
- args: buildOpenCodeArgs({ projectRoot, prompt, attachments, ...(model ? { model } : {}) }),
165
+ args: buildOpenCodeArgs({ projectRoot, prompt, attachments, effort, ...(model ? { model } : {}) }),
162
166
  cwd: projectRoot,
163
167
  env: openCodeEnvironment(process.env, { lease, scopes, imageStudio, memoryServer, mcpServers }),
164
168
  timeoutMs,
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  import { spawn } from 'node:child_process';
2
4
  import { t } from '../i18n.mjs';
3
5
 
package/src/asking.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // What the room should ask next.
2
4
  //
3
5
  // The maturity reading says where the archive is thin; it does not say what to do about it on a
@@ -1,6 +1,8 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  import { execFile } from 'node:child_process';
2
4
  import { t } from './i18n.mjs';
3
- import { access, readFile } from 'node:fs/promises';
5
+ import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
6
  import { homedir } from 'node:os';
5
7
  import { join } from 'node:path';
6
8
  import { promisify } from 'node:util';
@@ -43,6 +45,18 @@ export function parseOpenCodeAuthList({ stdout = '' }) {
43
45
  return { state: 'signed-out', detail: 'no providers configured', providers: [] };
44
46
  }
45
47
 
48
+ // `cursor-agent status` says «Not logged in», or who is signed in («Logged in as …»). A key in the
49
+ // environment (CURSOR_API_KEY) signs every run in on its own, so it counts too.
50
+ export function parseCursorStatus({ stdout = '', stderr = '' }, env = process.env) {
51
+ const said = stripAnsi(`${stdout}\n${stderr}`);
52
+ if (/not\s+logged\s+in|not\s+authenticated|log\s*in\s+first/i.test(said)) {
53
+ return env.CURSOR_API_KEY ? { state: 'signed-in', detail: 'via CURSOR_API_KEY' } : { state: 'signed-out', detail: 'not logged in' };
54
+ }
55
+ const who = said.match(/logged\s+in\s+(?:as\s+)?([^\s]+@[^\s]+|\S+)/i)?.[1];
56
+ if (who) return { state: 'signed-in', detail: `as ${who}` };
57
+ return env.CURSOR_API_KEY ? { state: 'signed-in', detail: 'via CURSOR_API_KEY' } : { state: 'unknown', detail: said.trim().split('\n')[0]?.slice(0, 120) || 'no answer from cursor-agent status' };
58
+ }
59
+
46
60
  export function geminiAuthState({ settings, hasOauth, hasApiKey }) {
47
61
  const type = settings?.security?.auth?.selectedType;
48
62
  if (type === 'gemini-api-key') return hasApiKey ? { state: 'signed-in', detail: 'via API key' } : { state: 'signed-out', detail: 'API key selected but not found' };
@@ -85,8 +99,9 @@ export async function probeGemini({ env = process.env } = {}) {
85
99
  return geminiAuthState({ settings, hasOauth, hasApiKey });
86
100
  }
87
101
 
88
- // What each CLI needs from the user. Commands are run in the user's terminal
89
- // by the setup wizard; install hints are only printed.
102
+ // What each CLI needs from the user. The room runs the sign-in itself; the same command typed in a
103
+ // terminal stays as the other way in (`terminal`, `terminalNote`), offered and never required.
104
+ // The setup wizard runs them in the user's terminal; install hints are only printed.
90
105
  export const AGENT_SETUP = {
91
106
  codex: {
92
107
  install: ['npm install -g @openai/codex', 'or install the ChatGPT desktop app, which bundles codex'],
@@ -114,27 +129,86 @@ export const AGENT_SETUP = {
114
129
  // Its browser flow has no local server to come back to: the page gives a code and the CLI
115
130
  // waits for it on its own input. The room asks for it and types it in.
116
131
  asksForCode: true,
132
+ terminalNote: 'Add --console to bill the work to the Anthropic Console instead of your subscription.',
117
133
  vendor: 'Anthropic',
118
134
  account: 'Signs in with a Claude account or with the Anthropic Console. An Anthropic API key works too.',
119
135
  paid: true,
120
136
  },
121
137
  gemini: {
122
138
  install: ['npm install -g @google/gemini-cli'],
123
- login: [],
124
- loginNote: 'Gemini signs in from its own prompt: run `gemini`, type /auth, pick "Use Gemini API key" (get one at aistudio.google.com/app/apikey) or Google login.',
139
+ // Gemini has no sign-in command: it signs in the first time it is asked something with
140
+ // "Login with Google" selected. Without a terminal it asks one yes/no, opens the browser by
141
+ // itself and waits on 127.0.0.1 for Google to come back, so the room needs no code and no
142
+ // address. The one-word question it is launched with is let through once the credentials are
143
+ // written: Google refuses free personal accounts only there, and a refused sign-in is undone
144
+ // (see geminiSignIn and loginAgent in server.mjs).
145
+ login: ['--prompt', 'ok'],
146
+ loginNote: 'Opens your browser to sign in with your Google account.',
147
+ browser: true,
148
+ methods: [
149
+ // The free personal account is refused by Google on this CLI (IneligibleTierError, 2026-10-17);
150
+ // the door stays for the accounts Google still serves, and says so.
151
+ { id: 'google', label: 'Google account', args: ['--prompt', 'ok'], note: 'Opens your browser to sign in with your Google account. Google refuses free personal accounts on this CLI; if it refuses yours, paste a Gemini API key from Google AI Studio instead.' },
152
+ ],
153
+ room: {
154
+ display: 'gemini · Login with Google',
155
+ flow: 'gemini-google',
156
+ // It asks "Do you want to continue? [Y/n]" first; the click on the button was the yes.
157
+ consent: true,
158
+ // With CI set the CLI refuses to open a browser and gives up without a terminal.
159
+ ci: false,
160
+ // A key in the environment would sign it in with the key instead of the account chosen.
161
+ dropEnv: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
162
+ // One process, so cancelling stops the one that holds the browser's way back.
163
+ env: { GEMINI_CLI_NO_RELAUNCH: 'true' },
164
+ },
165
+ // In a terminal it is its own prompt that signs in (the setup wizard hands it over whole).
125
166
  interactive: true,
167
+ terminal: 'gemini',
168
+ terminalNote: 'Then type /auth and pick "Use Gemini API key" with a key from aistudio.google.com/app/apikey. "Login with Google" is refused for free personal accounts.',
126
169
  vendor: 'Google',
127
- account: 'Signs in with a Google account and has a free tier. A Gemini API key from AI Studio works too.',
128
- paid: false,
170
+ account: 'Connects with a Gemini API key from Google AI Studio. A Google account works only where Google still accepts it: free personal accounts are refused.',
171
+ // Whether it costs depends on the key's project, not on the CLI: no «free option» promised.
172
+ paid: null,
129
173
  },
130
174
  opencode: {
131
175
  install: ['brew install opencode', 'or: npm install -g opencode-ai'],
132
176
  login: ['auth', 'login'],
133
177
  loginNote: 'Pick a provider and paste its key or complete its OAuth flow.',
178
+ // Its keys are pasted from the room (see openCodeProviders); these are the providers it signs
179
+ // in to with an account. `-p` skips its provider list, so the room never draws a menu. Only
180
+ // the ones whose owner says a third party may sign in this way: GitHub supports Copilot in
181
+ // OpenCode since 2026-01. Left out on purpose: a Claude subscription (Anthropic does not allow
182
+ // claude.ai sign-ins in other tools), and ChatGPT and GitLab, until OpenAI or GitLab say so in
183
+ // writing. They stay one `opencode auth login` away, in the terminal, for whoever chooses them.
184
+ browser: true,
185
+ methods: [
186
+ {
187
+ id: 'github-copilot',
188
+ label: 'GitHub Copilot',
189
+ args: ['auth', 'login', '-p', 'github-copilot'],
190
+ note: 'Gives you a code to type at github.com/login/device: the work goes against your Copilot subscription (Pro, Pro+, Business or Enterprise).',
191
+ // It asks which GitHub first; the room takes github.com. Enterprise is in the terminal.
192
+ room: { deviceCode: true, answers: [{ prompt: 'Select GitHub deployment type', send: '\r' }] },
193
+ },
194
+ ],
195
+ terminalNote: 'Pick a provider, then paste its key or sign in with its account. GitHub Enterprise and the other providers are in its list.',
134
196
  vendor: 'OpenCode',
135
197
  account: 'Brings no model of its own: you point it at a provider you already use, in the cloud or on this computer.',
136
198
  paid: null,
137
199
  },
200
+ // Cursor's agent CLI (2026-10-17): `login` opens the browser and waits for Cursor to come back.
201
+ // It has no npm package; its own installer is the way in, typed in a terminal.
202
+ cursor: {
203
+ install: ['curl https://cursor.com/install -fsS | bash'],
204
+ login: ['login'],
205
+ loginNote: 'Opens your browser to sign in with your Cursor account.',
206
+ browser: true,
207
+ terminal: 'cursor-agent login',
208
+ vendor: 'Cursor',
209
+ account: 'Signs in with your Cursor account and uses its plan. For now it reads and answers; it does not write in your project.',
210
+ paid: true,
211
+ },
138
212
  };
139
213
 
140
214
  // Installing a CLI from the room itself. npm is the common denominator: every one of these
@@ -155,6 +229,10 @@ export const TAKES_KEY = new Set(['claude', 'gemini', 'opencode']);
155
229
  export const SIGN_IN_URL = /https?:\/\/\S+/;
156
230
  export const CODE_PROMPT = /paste\s+(?:the\s+)?code|enter\s+(?:the\s+)?code|c[oó]digo\s*[:>]/i;
157
231
  export const looksLikeCodePrompt = (line) => CODE_PROMPT.test(String(line ?? ''));
232
+ // The other way round: a device sign-in shows a code to type on the provider's page
233
+ // ("Enter code: 3475-C193"), and the room has to show it, not ask for one.
234
+ export const DEVICE_CODE = /enter\s+(?:the\s+|this\s+)?code:?\s+([A-Z0-9]{4,}(?:-[A-Z0-9]{4,})+)/i;
235
+ export const deviceCodeIn = (line) => String(line ?? '').match(DEVICE_CODE)?.[1] ?? null;
158
236
 
159
237
  export function accountNoteFor(id) {
160
238
  // Said in the room's language when it is asked for, not when this file is imported.
@@ -183,10 +261,10 @@ export function installPlanFor(agent, { prefix = null } = {}) {
183
261
  };
184
262
  }
185
263
 
186
- // How the room can (re)connect an agent. Codex and Claude sign in with a
187
- // browser flow their own CLI drives, so the server can run them and stream the
188
- // URL; Gemini and OpenCode need their interactive prompt, so the user gets the
189
- // exact command instead.
264
+ // How the room can (re)connect an agent. Codex, Claude and Gemini sign in with a browser flow
265
+ // their own CLI drives, so the server can run them and stream what they say; OpenCode too, for
266
+ // the providers in its `methods`, and its keys are pasted. Every plan also carries `terminal`:
267
+ // the same sign-in typed by hand, for whoever prefers it.
190
268
  export function loginPlanFor(agent, { method = null } = {}) {
191
269
  const setup = AGENT_SETUP[agent.id];
192
270
  if (!setup) return null;
@@ -194,17 +272,71 @@ export function loginPlanFor(agent, { method = null } = {}) {
194
272
  // Which door: the one asked for, the first one the CLI offers, or the single flow it has.
195
273
  const chosen = setup.methods?.find((one) => one.id === method) ?? setup.methods?.[0] ?? null;
196
274
  const args = chosen?.args ?? setup.login;
275
+ const room = headless ? { ...(setup.room ?? {}), ...(chosen?.room ?? {}) } : {};
197
276
  return {
198
277
  headless,
199
278
  command: agent.path ?? agent.id,
200
279
  args: headless ? args : [],
201
- display: headless ? `${agent.id} ${args.join(' ')}` : agent.id === 'gemini' ? 'gemini # then type /auth' : `${agent.id} ${setup.login.join(' ')}`,
280
+ display: room.display ?? `${agent.id} ${(headless ? args : setup.login).join(' ')}`,
202
281
  note: chosen?.note ?? setup.loginNote,
282
+ terminal: { command: setup.terminal ?? `${agent.id} ${setup.login.join(' ')}`, note: setup.terminalNote ?? setup.loginNote },
203
283
  install: setup.install,
204
284
  // What the room offers as a choice, and what it must be ready to be asked for.
205
285
  methods: setup.methods ? setup.methods.map(({ id, label, note }) => ({ id, label, note })) : null,
206
286
  method: chosen?.id ?? null,
207
287
  asksForCode: Boolean(headless && setup.asksForCode),
288
+ // How the room runs it, when the CLI needs more than its arguments (see AGENT_SETUP.gemini.room).
289
+ ...(room.flow ? { flow: room.flow } : {}),
290
+ ...(room.consent ? { consent: true } : {}),
291
+ ...(room.ci === false ? { ci: false } : {}),
292
+ ...(room.env ? { env: room.env } : {}),
293
+ ...(room.dropEnv ? { dropEnv: room.dropEnv } : {}),
294
+ // A code to show rather than one to paste, and the menu questions the click already answered.
295
+ ...(room.deviceCode ? { deviceCode: true } : {}),
296
+ ...(room.answers ? { answers: room.answers } : {}),
297
+ };
298
+ }
299
+
300
+ // The yes/no Gemini asks before opening the browser, when it has no terminal to draw a dialog in.
301
+ export const CONSENT_PROMPT = /do you want to continue\?\s*\[y\/n\]/i;
302
+ export const looksLikeConsentPrompt = (line) => CONSENT_PROMPT.test(String(line ?? ''));
303
+
304
+ const GEMINI_REFUSED = /IneligibleTierError|no longer supported for Gemini Code Assist|migrate to the Antigravity/i;
305
+
306
+ // Gemini's Google sign-in, run from the room. Before: "Login with Google" selected in its own
307
+ // settings, and the account it had put aside, so the run signs in anew instead of answering with
308
+ // the old one. During: `signedIn` says when the credentials are written, which is when the room
309
+ // stops the run. After a sign-in that did not finish: `undo` puts both files back as they were.
310
+ // MADRE never reads the token: it moves the file it is in and checks that it exists.
311
+ export function geminiSignIn({ env = process.env, verifyMs = 30000 } = {}) {
312
+ const dir = join(env.GEMINI_CLI_HOME ?? homedir(), '.gemini');
313
+ const settingsFile = join(dir, 'settings.json');
314
+ const credsFile = join(dir, 'oauth_creds.json');
315
+ let before = null;
316
+ const readIfThere = (path) => readFile(path, 'utf8').catch(() => null);
317
+ const putBack = (path, previous) => (previous === null ? rm(path, { force: true }) : writeFile(path, previous, { mode: 0o600 }));
318
+ return {
319
+ async prepare() {
320
+ await mkdir(dir, { recursive: true });
321
+ before = { settings: await readIfThere(settingsFile), creds: await readIfThere(credsFile) };
322
+ let settings = {};
323
+ try { settings = JSON.parse(before.settings ?? '{}') ?? {}; } catch { settings = {}; }
324
+ settings.security = { ...(settings.security ?? {}), auth: { ...(settings.security?.auth ?? {}), selectedType: 'oauth-personal' } };
325
+ await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`);
326
+ if (before.creds !== null) await rm(credsFile, { force: true });
327
+ },
328
+ signedIn: () => exists(credsFile),
329
+ // Google lets a free personal account sign in and refuses it at the first question
330
+ // (IneligibleTierError, 2026-10-17): a sign-in is only good once that question was not refused.
331
+ refused: (line) => GEMINI_REFUSED.test(String(line ?? '')),
332
+ // How long that first question is waited for before the run is stopped and the sign-in kept.
333
+ verifyMs,
334
+ async undo() {
335
+ if (!before) return;
336
+ await putBack(settingsFile, before.settings);
337
+ await putBack(credsFile, before.creds);
338
+ before = null;
339
+ },
208
340
  };
209
341
  }
210
342
 
@@ -215,6 +347,7 @@ export async function probeAgentAuth(agent) {
215
347
  case 'codex': return parseCodexLoginStatus(await run(agent.path, ['login', 'status']));
216
348
  case 'claude': return parseClaudeAuthStatus(await run(agent.path, ['auth', 'status', '--json']));
217
349
  case 'opencode': return parseOpenCodeAuthList(await run(agent.path, ['auth', 'list']));
350
+ case 'cursor': return parseCursorStatus(await run(agent.path, ['status']));
218
351
  case 'gemini': return probeGemini();
219
352
  default: return { state: 'unknown', detail: 'no probe for this agent' };
220
353
  }
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // The phones that may reach this Mac, and the networks where they may.
2
4
  //
3
5
  // One file, `~/.pulse/devices.json`, owner-only, shared by every room on this Mac: any of them can
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // Where the person was, on the Mac and on the phone (docs/MOBILE.md, MOV-005).
2
4
  //
3
5
  // One small file for the whole Mac, `~/.pulse/handoff.json`, owner-only: the last place each side
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // Who keeps the bridge up. Every room on this Mac offers to; the port decides.
2
4
  //
3
5
  // No process of its own and nothing to start: each room, every few seconds, asks whether a phone
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // Which network this Mac is on, and how a phone on it reaches the Mac.
2
4
  //
3
5
  // A network is known by its router: the hardware address of the default gateway, hashed. Two
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // What a phone may do in a room (docs/MOBILE.md, section 4).
2
4
  //
3
5
  // A list of what passes, not of what does not: a route the room grows tomorrow stays on the Mac
@@ -15,7 +17,7 @@ import { t } from '../i18n.mjs';
15
17
 
16
18
  // Every file the room's page loads: one missing here and app.js cannot import it, so the whole page
17
19
  // stops on the phone (test/phone-bridge.test.mjs reads app.js's imports to keep this whole).
18
- const STATIC = new Set(['/', '/app.js', '/nostromo-engine.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/editor.js', '/routing.js', '/abduction.js', '/styles.css', '/review-ask.js', '/loader.js', '/tour.js', '/tour-chapters.js', '/touch.js']);
20
+ const STATIC = new Set(['/', '/app.js', '/nostromo-engine.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/editor.js', '/routing.js', '/abduction.js', '/styles.css', '/review-ask.js', '/loader.js', '/tour.js', '/tour-chapters.js', '/touch.js', '/attribution.js', '/power.js', '/wallpaper.js', '/neural.js', '/hardware.js', '/code-tools.js', '/market.js', '/market-view.js', '/market-art.js']);
19
21
 
20
22
  const READ = new Set([
21
23
  '/api/state', '/api/events', '/api/chats', '/api/version', '/api/whats-new', '/api/glance', '/api/handoff',
package/src/bridge/qr.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // The QR a phone scans to pair with this Mac: text in, a grid of dark and light modules out, and
2
4
  // an SVG of that grid the room sizes and colours with CSS.
3
5
  //
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // The bridge: the one thing on this Mac a phone can reach (docs/MOBILE.md, MOV-001).
2
4
  //
3
5
  // Rooms keep listening on 127.0.0.1 and keep refusing anything that does not call them by that
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // What each CLI can do from MADRE, verified against the installed versions on
2
4
  // 2026-09-16 (Codex 0.153, Claude Code 2.1, Gemini CLI 0.60, OpenCode 1.18).
3
5
  // The room shows this so a human knows whom to ask for what, and an
@@ -33,6 +35,15 @@ export const CAPABILITIES = {
33
35
  imageGen: false,
34
36
  web: { how: 'policy: allow google_web_search, web_fetch', note: 'Off until turned on in Settings › Agents › Web access.' },
35
37
  },
38
+ // Cursor (2026-10-17) reads only, in its own ask mode: writing waits until its confinement has
39
+ // been tried against the real CLI, as OpenCode's was. Attachments are named in the briefing.
40
+ cursor: {
41
+ read: true,
42
+ imageIn: false,
43
+ write: false,
44
+ imageGen: false,
45
+ web: false,
46
+ },
36
47
  opencode: {
37
48
  read: true,
38
49
  imageIn: { how: '-f <file>', note: 'Files attach to the message.' },
package/src/chats.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // A project has one memory and many conversations.
2
4
  //
3
5
  // The archive, the crew, the modules and the privacy list belong to the project: they are what
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  import { execFile } from 'node:child_process';
2
4
  import { access, mkdir, mkdtemp, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
3
5
  import { tmpdir } from 'node:os';
@@ -224,7 +226,9 @@ async function diffOne(root, before, prefix = '', exclude = []) {
224
226
  if (!files.length) return { files: [], stat: '' };
225
227
  const only = files.length < listed.length ? ['--', ...files.flatMap((file) => [file.path.slice(prefix.length), ...(file.from ? [file.from.slice(prefix.length)] : [])])] : [];
226
228
  const stat = (await git(root, ['diff', '--no-ext-diff', '--no-textconv', '--stat=100', before.tree, after, ...only], { via })).trim();
227
- return { files, stat: prefix && stat ? stat.split('\n').map((line) => (line.startsWith(' ') ? ` ${prefix}${line.trimStart()}` : line)).join('\n') : stat };
229
+ // The tree the turn left is kept with what it changed (the code workspace reads it back): the
230
+ // agent's own lines stay its own even after the human edits the file again.
231
+ return { files, afterTree: after, stat: prefix && stat ? stat.split('\n').map((line) => (line.startsWith(' ') ? ` ${prefix}${line.trimStart()}` : line)).join('\n') : stat };
228
232
  }
229
233
 
230
234
  // What changed since the checkpoint, in the project and in every nested repository, paths
@@ -241,6 +245,19 @@ export async function diffCheckpoint(root, checkpoint) {
241
245
  return { afterTree: outer.afterTree ?? null, files, stat, forbidden: files.filter((file) => forbiddenIn(file.path)).map((file) => file.path) };
242
246
  }
243
247
 
248
+ // What a file said at a photograph, or in the tree a turn left behind, read from git and never from
249
+ // the disk. `null` when git no longer has it (a shadow repository gone with a restart, a tree git
250
+ // pruned), when the file was not there, or when the path is not a path inside the project.
251
+ const CHECKPOINT_ID = /^[0-9A-Za-z][\w-]{0,79}$/;
252
+ const TREE_ID = /^[0-9a-f]{40,64}$/;
253
+ export async function readAt(root, { checkpointId = null, tree = null, path = '' } = {}) {
254
+ const inner = String(path ?? '');
255
+ if (!inner || inner.startsWith('/') || inner.startsWith('-') || inner.split('/').includes('..')) return null;
256
+ const rev = checkpointId ? (CHECKPOINT_ID.test(String(checkpointId)) ? `${CHECKPOINT_REF}/${checkpointId}` : null) : (TREE_ID.test(String(tree ?? '')) ? tree : null);
257
+ if (!rev) return null;
258
+ try { return await git(root, ['show', `${rev}:${inner}`]); } catch { return null; }
259
+ }
260
+
244
261
  // Which repository a project-relative path belongs to, and the path inside it.
245
262
  function repoFor(root, checkpoint, path) {
246
263
  for (const part of checkpoint.nested ?? []) {
package/src/choices.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // An agent asking the human to choose.
2
4
  //
3
5
  // The room already has one fenced channel, ```pulse, and it puts other agents to work. This is
package/src/cli-args.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // Tiny argv reader for the MADRE CLI. Accepts `--name value` and
2
4
  // `--name=value`, and remembers which options the user set explicitly so the
3
5
  // room can choose sensible defaults (like the next free port) only when they
package/src/cold.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // Cold zones: the part of the archive nothing has ever reached for.
2
4
  //
3
5
  // A memory is cold when three things are true at once, and the three together are what make the
package/src/commands.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  import { execFile } from 'node:child_process';
2
4
  import { access } from 'node:fs/promises';
3
5
  import { join } from 'node:path';