@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
package/src/files.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 { createHash, randomUUID } from 'node:crypto';
2
4
  import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
3
5
  import { dirname, extname, join, resolve, sep } from 'node:path';
@@ -96,6 +98,36 @@ export async function walkProject(root, { maxFiles = 20000, ttlMs = 15000 } = {}
96
98
  return files;
97
99
  }
98
100
 
101
+ // What changed on disk since a moment, by modification time (2026-10-14). A restore point sees
102
+ // only what git sees, so a PDF a command wrote into an ignored `output/` was there and nobody said
103
+ // so: the Finder did not refresh and nothing told whose it was. This looks at the files
104
+ // themselves, skipping the same heavy folders the search skips and MADRE's own `.pulse`/`.madre`,
105
+ // bounded so a large project costs a fraction of a second. Newest first.
106
+ const UNWATCHED = new Set([...SHALLOW, '.pulse', '.madre']);
107
+ export async function filesChangedSince(root, since, { maxFiles = 8000, limit = 40 } = {}) {
108
+ const from = Number(since) - 250;
109
+ if (!Number.isFinite(from)) return [];
110
+ const found = [];
111
+ let seen = 0;
112
+ async function walk(dir, prefix, depth) {
113
+ if (seen >= maxFiles || depth > 14) return;
114
+ let entries = [];
115
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
116
+ const files = [];
117
+ for (const entry of entries) {
118
+ if (SKIP.has(entry.name)) continue;
119
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
120
+ if (entry.isDirectory()) { if (!UNWATCHED.has(entry.name)) await walk(join(dir, entry.name), rel, depth + 1); continue; }
121
+ if (entry.isFile()) files.push(rel);
122
+ }
123
+ seen += files.length;
124
+ const stats = await Promise.all(files.map((rel) => stat(join(root, rel)).then((info) => [rel, info.mtimeMs], () => null)));
125
+ for (const one of stats) if (one && one[1] >= from) found.push({ path: one[0], at: one[1] });
126
+ }
127
+ await walk(root, '', 0);
128
+ return found.sort((a, b) => b.at - a.at).slice(0, limit).map((one) => one.path);
129
+ }
130
+
99
131
  export function scoreFile(path, query) {
100
132
  const q = query.toLowerCase();
101
133
  if (!q) return 1;
package/src/github.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
  // GitHub, as the GITHUB module and its MCP server both speak to it.
2
4
  //
3
5
  // Two ways in, and the order matters. First the `gh` CLI, if this computer has it and it is
package/src/heartbeat.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
  // «Still in use»: once a day, every copy of MADRE tells madre.run it is on (2026-10-13).
2
4
  //
3
5
  // What goes, and nothing else: a random identifier this copy made for itself once
package/src/i18n.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 room's language, on this side of the wire.
2
4
  //
3
5
  // The catalogue is the page's: public/es.js, one file for the whole product, because a sentence
@@ -24,9 +26,37 @@ export function setLanguage(id) {
24
26
  return current;
25
27
  }
26
28
 
29
+ // «Mac» only on a Mac (2026-10-15): MADRE installs on Linux too, and a sentence about the machine
30
+ // it runs on says «computer» there. Same rule as public/i18n.js (hostWords); hardware named on
31
+ // purpose («a Mac with an M chip») has no article in front and is left alone.
32
+ let hostIsMac = process.platform === 'darwin';
33
+ export const setHostIsMac = (value) => { hostIsMac = Boolean(value); return hostIsMac; };
34
+ export function hostWords(text) {
35
+ if (hostIsMac || typeof text !== 'string' || !text.includes('Mac')) return text;
36
+ return text
37
+ .replace(/\b(this|your|the|This|Your|The) Mac\b/g, '$1 computer')
38
+ .replace(/(^|[^\p{L}])(esta|tu|la|Esta|Tu|La) Mac\b/gu, '$1$2 computadora');
39
+ }
40
+
41
+ // A sentence MADRE wrote in one language, quoted in the other. A failure is recorded in the
42
+ // language the room spoke when it happened, and @madre's round summary quoted it as it was: a
43
+ // Spanish summary said «(Google rejected the Gemini credentials…)» (manual tests, 2026-10-17).
44
+ // Only whole sentences of the catalogue are swapped, never a word inside one, so ids, commands and
45
+ // whatever an agent wrote stay as they are.
46
+ let sentencePairs = null;
47
+ export function inLanguage(text, lang) {
48
+ let out = String(text ?? '');
49
+ sentencePairs ??= Object.entries(ES).filter(([en, es]) => typeof es === 'string' && en !== es && en.length >= 24 && /[.!?»)]$/.test(en));
50
+ for (const [en, es] of sentencePairs) {
51
+ const [from, to] = lang === 'es' ? [en, hostWords(es)] : [hostWords(es), en];
52
+ if (out.includes(from)) out = out.split(from).join(to);
53
+ }
54
+ return out;
55
+ }
56
+
27
57
  export function t(text, vars = null) {
28
58
  if (typeof text !== 'string' || !text) return text;
29
- const said = CATALOGUES[current]?.[text] ?? text;
59
+ const said = hostWords(CATALOGUES[current]?.[text] ?? text);
30
60
  if (!vars) return said;
31
61
  return said.replace(/\{(\w+)\}/g, (whole, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : whole));
32
62
  }
@@ -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 { fileURLToPath } from 'node:url';
2
4
  import { resolveGeminiKey } from './mcp/image-server.mjs';
3
5
 
package/src/json-file.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
  // Small files that more than one thing changes at once (2026-10-12, Lote 2 of the 1.0 review): the
2
4
  // tour, the review ask, a project's record of where it is, the list of a room's conversations.
3
5
  // Each was read, changed and written with nothing between two changes, so the second one wrote
package/src/launch.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 is shown of a command, and what is never shown.
2
4
  //
3
5
  // THE LAUNCH floor in the core prints the exact command MADRE would run, because a person who is
package/src/lease.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 { mkdir, readdir, realpath, stat } from 'node:fs/promises';
2
4
  import { join, relative, sep } from 'node:path';
3
5
  import { declaredTypeFor } from './files.mjs';
@@ -119,7 +121,9 @@ export function leaseInstructions({ outDir, agentId, scopes = { write: true, ima
119
121
  : canImage ? `You can generate images; save them${create ? ' where images live in this project, or in the scratch folder,' : ' into the lease directory'} with a descriptive file name.`
120
122
  : couldImage ? 'Image generation is switched off for this request; if asked for an image, say so and do not attempt it.'
121
123
  : 'You cannot generate images from this CLI; if asked for one, say so plainly instead of attempting it.',
122
- 'Do this work yourself. Do not hand it to a subagent or spawn another agent: a subagent writes outside this lease, so nothing it did would be checked, shown to the human or undoable — and the tools for it are blocked, which can leave you waiting instead of answering.',
124
+ // «Do not hand it to another agent» read as «do not plan»: with a lease, agents stopped proposing
125
+ // pulse plans altogether (2026-10-14). What is barred is the CLI's own subagents, not the room's.
126
+ 'Do this work yourself rather than through your CLI\'s own subagents or spawned agents: a subagent writes outside this lease, so nothing it did would be checked, shown to the human or undoable — and the tools for it are blocked, which can leave you waiting instead of answering. A pulse plan that puts the room\'s other agents to work is different and still allowed.',
123
127
  'List the files you created (or "none") before any plan block; nothing may follow a plan block.',
124
128
  ].filter(Boolean).join('\n');
125
129
  }
@@ -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 was worked on last on this Mac, for the lobby (2026-10-10): the newest conversations of
2
4
  // every room the lobby knows, open or asleep, each with its last few lines, and the summary MADRE
3
5
  // writes of them with its local model. Everything here READS: a room's list of conversations
package/src/local-ai.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
  // Which projects use the local AI (Ollama), decided from Home (2026-10-13).
2
4
  //
3
5
  // Ollama is one per computer and its switches live in ~/.pulse/config.json, which every project
package/src/looks.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
  // How the person wants MADRE to look, once for every page (2026-10-12). Each project is its own
2
4
  // origin — another port — and Home is one more, so what a browser keeps for one is invisible to
3
5
  // the others: the dark theme chosen in Home left every project light. The choices live here, in
@@ -6,9 +8,10 @@
6
8
  // so a page paints in the last known look before it hears from here.
7
9
  //
8
10
  // Only what the pages share: the theme, the accent, the wallpaper and its dials, the bubbles and
9
- // the text of the conversation. How Home lays out its own cards stays in Home's browser.
11
+ // the text of the conversation. How Home is arranged lives beside it, in home.json (below).
10
12
 
11
- import { readFile } from 'node:fs/promises';
13
+ import { createHash } from 'node:crypto';
14
+ import { readFile, rm } from 'node:fs/promises';
12
15
  import { join } from 'node:path';
13
16
  import { oneAtATime, writeWhole } from './json-file.mjs';
14
17
 
@@ -23,6 +26,8 @@ export const LOOK_KEYS = {
23
26
  'pulse.wallpaper.blur': NUMBER,
24
27
  'pulse.bubbles': CHOICE,
25
28
  'pulse.text': CHOICE,
29
+ // The maps drawn as neurons (public/neural.js): 'on', or nothing.
30
+ 'pulse.neural': CHOICE,
26
31
  };
27
32
  const looksFile = (stateRoot) => join(stateRoot, 'looks.json');
28
33
 
@@ -86,3 +91,123 @@ export function changeLooks(stateRoot, patch, now = new Date()) {
86
91
  return { looks, saved: true };
87
92
  });
88
93
  }
94
+
95
+ // HOME, ARRANGED ONCE (2026-10-16). Home is shown by whichever process answers /salas — the
96
+ // Coordinator's, or any project's — and each is another port, so the arrangement Home kept in the
97
+ // browser was lost on every other page and whenever MADRE came back on another port. What the
98
+ // person arranged lives here, in ~/.pulse/home.json: the tiles and their sizes, the order of the
99
+ // projects, the projects on the bar, and how the cards read. Kept as the page keeps them (the same
100
+ // keys, the same text), each checked for its shape before it is written.
101
+ const homeFile = (stateRoot) => join(stateRoot, 'home.json');
102
+ const HOME_TEXT = 8000;
103
+ const isIds = (value) => Array.isArray(value) && value.length <= 500 && value.every((id) => typeof id === 'string' && id.length <= 200);
104
+ export const HOME_KEYS = {
105
+ 'pulse.home.layout': (value) => { try { const raw = JSON.parse(value); return Boolean(raw) && typeof raw === 'object' && !Array.isArray(raw) && (raw.order === undefined || isIds(raw.order)); } catch { return false; } },
106
+ 'pulse.home.projects': (value) => { try { return isIds(JSON.parse(value)); } catch { return false; } },
107
+ 'pulse.lobbyBar': (value) => { try { return isIds(JSON.parse(value)); } catch { return false; } },
108
+ 'pulse.home.cards': (value) => CHOICE.test(value),
109
+ 'pulse.home.text': (value) => CHOICE.test(value),
110
+ 'pulse.home.order': (value) => CHOICE.test(value),
111
+ };
112
+ export async function readHome(stateRoot) {
113
+ const raw = await readFile(homeFile(stateRoot), 'utf8').then((text) => JSON.parse(text)).catch(() => null);
114
+ const home = {};
115
+ for (const [key, valid] of Object.entries(HOME_KEYS)) {
116
+ const value = raw?.home?.[key];
117
+ home[key] = typeof value === 'string' && value.length <= HOME_TEXT && valid(value) ? value : null;
118
+ }
119
+ return { home, saved: Boolean(raw && typeof raw.home === 'object') };
120
+ }
121
+ export function changeHome(stateRoot, patch, now = new Date()) {
122
+ return oneAtATime(homeFile(stateRoot), async () => {
123
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { error: 'home' };
124
+ for (const [key, value] of Object.entries(patch)) {
125
+ if (!Object.hasOwn(HOME_KEYS, key)) return { error: 'key' };
126
+ if (value !== null && (typeof value !== 'string' || value.length > HOME_TEXT || !HOME_KEYS[key](value))) return { error: 'value' };
127
+ }
128
+ const { home } = await readHome(stateRoot);
129
+ Object.assign(home, patch);
130
+ await writeWhole(homeFile(stateRoot), `${JSON.stringify({ home, at: new Date(now).toISOString() }, null, 2)}\n`);
131
+ return { home, saved: true };
132
+ });
133
+ }
134
+
135
+ // YOUR OWN WALLPAPER (1.0.3). One picture of the person's, for every page that chooses it (the
136
+ // choice is still each page's own: `pulse.wallpaper` = 'custom'). The page reads the picture,
137
+ // fits it (no longer side than WALLPAPER_SIDE), encodes it and takes its colours for the veil, the
138
+ // mesh and the tint, the way the wallpapers that ship were made by hand; here it is kept as it
139
+ // came: the picture, and beside it what it is (`custom.json`). Only a picture is kept: its first
140
+ // bytes say WebP, JPEG or PNG, or nothing is written. Never an SVG: it can carry a script.
141
+ export const WALLPAPER_BYTES = 8 * 1024 * 1024;
142
+ const wallDir = (stateRoot) => join(stateRoot, 'wallpaper');
143
+ const wallMeta = (stateRoot) => join(wallDir(stateRoot), 'custom.json');
144
+ const PICTURES = [
145
+ { type: 'image/webp', ext: 'webp', is: (b) => b.length > 12 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP' },
146
+ { type: 'image/jpeg', ext: 'jpg', is: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
147
+ { type: 'image/png', ext: 'png', is: (b) => b.length > 8 && b.toString('latin1', 1, 4) === 'PNG' && b[0] === 0x89 },
148
+ ];
149
+ const RGB = (value) => Array.isArray(value) && value.length === 3 && value.every((n) => Number.isInteger(n) && n >= 0 && n <= 255);
150
+ const SCHEME = (value) => Boolean(value) && RGB(value.veil) && RGB(value.tint) && Array.isArray(value.mesh) && value.mesh.length === 3 && value.mesh.every(RGB);
151
+ const PERCENT = (value) => Number.isFinite(value) && value >= 0 && value <= 100;
152
+ // What a page sends about the picture, checked one field at a time.
153
+ function wallShape(meta) {
154
+ if (!meta || typeof meta !== 'object') return null;
155
+ const w = Number(meta.w);
156
+ const h = Number(meta.h);
157
+ if (!Number.isInteger(w) || !Number.isInteger(h) || w < 1 || h < 1 || w > 20000 || h > 20000) return null;
158
+ if (!SCHEME(meta.palette?.dark) || !SCHEME(meta.palette?.light)) return null;
159
+ const x = Number(meta.focus?.x ?? 50);
160
+ const y = Number(meta.focus?.y ?? 50);
161
+ if (!PERCENT(x) || !PERCENT(y)) return null;
162
+ const pick = (one) => ({ veil: one.veil, tint: one.tint, mesh: one.mesh });
163
+ return { w, h, focus: { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }, palette: { dark: pick(meta.palette.dark), light: pick(meta.palette.light) } };
164
+ }
165
+ // The picture kept, as a page reads it: its version (a hash of its bytes), its type, its size, where
166
+ // it is framed and its colours. `null` when there is none, or what is there cannot be trusted.
167
+ export async function readWallpaper(stateRoot) {
168
+ const raw = await readFile(wallMeta(stateRoot), 'utf8').then((text) => JSON.parse(text)).catch(() => null);
169
+ const shape = wallShape(raw);
170
+ const kind = PICTURES.find((one) => one.type === raw?.type);
171
+ if (!shape || !kind || typeof raw.v !== 'string' || !/^[0-9a-f]{8,64}$/.test(raw.v)) return null;
172
+ return { v: raw.v, type: kind.type, ...shape };
173
+ }
174
+ export async function wallpaperBytes(stateRoot) {
175
+ const meta = await readWallpaper(stateRoot);
176
+ if (!meta) return null;
177
+ const kind = PICTURES.find((one) => one.type === meta.type);
178
+ const bytes = await readFile(join(wallDir(stateRoot), `custom.${kind.ext}`)).catch(() => null);
179
+ return bytes ? { bytes, type: meta.type, v: meta.v } : null;
180
+ }
181
+ export function saveWallpaper(stateRoot, bytes, meta) {
182
+ return oneAtATime(wallMeta(stateRoot), async () => {
183
+ if (!Buffer.isBuffer(bytes) || !bytes.length) return { error: 'empty' };
184
+ if (bytes.length > WALLPAPER_BYTES) return { error: 'size' };
185
+ const kind = PICTURES.find((one) => one.is(bytes));
186
+ if (!kind) return { error: 'type' };
187
+ const shape = wallShape(meta);
188
+ if (!shape) return { error: 'meta' };
189
+ const v = createHash('sha256').update(bytes).digest('hex').slice(0, 16);
190
+ await writeWhole(join(wallDir(stateRoot), `custom.${kind.ext}`), bytes);
191
+ for (const other of PICTURES) if (other !== kind) await rm(join(wallDir(stateRoot), `custom.${other.ext}`), { force: true });
192
+ await writeWhole(wallMeta(stateRoot), `${JSON.stringify({ v, type: kind.type, ...shape, at: new Date().toISOString() }, null, 2)}\n`);
193
+ return { custom: { v, type: kind.type, ...shape } };
194
+ });
195
+ }
196
+ // Where the picture is framed: the point of it that stays in view when the screen crops it.
197
+ export function frameWallpaper(stateRoot, focus) {
198
+ return oneAtATime(wallMeta(stateRoot), async () => {
199
+ const raw = await readFile(wallMeta(stateRoot), 'utf8').then((text) => JSON.parse(text)).catch(() => null);
200
+ if (!raw) return { error: 'none' };
201
+ const shape = wallShape({ ...raw, focus });
202
+ if (!shape) return { error: 'value' };
203
+ await writeWhole(wallMeta(stateRoot), `${JSON.stringify({ ...raw, focus: shape.focus, at: new Date().toISOString() }, null, 2)}\n`);
204
+ return { custom: await readWallpaper(stateRoot) };
205
+ });
206
+ }
207
+ export function dropWallpaper(stateRoot) {
208
+ return oneAtATime(wallMeta(stateRoot), async () => {
209
+ for (const kind of PICTURES) await rm(join(wallDir(stateRoot), `custom.${kind.ext}`), { force: true });
210
+ await rm(wallMeta(stateRoot), { force: true });
211
+ return { custom: null };
212
+ });
213
+ }
@@ -0,0 +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
3
+ // The public half of the Market's key (scripts/market-key.mjs). The catalog at madre.run/mercado/
4
+ // is signed with the private half, which lives only on the author's computer; a catalog this key
5
+ // does not verify is not read, and nothing in it is installed (src/market-remote.mjs).
6
+ export const MARKET_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
7
+ MCowBQYDK2VwAyEA4/DXYprFh2wfiOPpkJheWR0uEtJA+Wl3+AnJXE1YGt0=
8
+ -----END PUBLIC KEY-----`;
@@ -0,0 +1,239 @@
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
+ // THE MARKET, from madre.run (2026-10-18). What the Market shows and the modules it hands out do
4
+ // not wait for a new MADRE: they live in a catalog the author publishes at madre.run/mercado/,
5
+ // signed with a key only the author holds (scripts/market-key.mjs). MADRE reads it when it opens —
6
+ // at most every ten minutes, however many projects open at once — and keeps the last one it could
7
+ // verify, so the Market works without internet with what it knew.
8
+ //
9
+ // catalogo.json the catalog: shelves, the featured order, and one entry per module — the
10
+ // words and drawings of MADRE's own, and for a new module its file, pinned by
11
+ // its sha256 and size.
12
+ // catalogo.json.sig the Ed25519 signature of those exact bytes, in base64.
13
+ //
14
+ // A catalog the key does not verify is not read. A module file whose sha256 is not the one the
15
+ // signed catalog names is not installed. A catalog older than the one already kept is refused, so
16
+ // an old signed catalog cannot be served again to roll a module back. What a module may do once
17
+ // installed is the same as any module installed by hand: the checks of src/modules/index.mjs.
18
+ //
19
+ // A module got from the Market is remembered in market/installed.json. When the catalog names a
20
+ // newer file for it, MADRE installs that one on its own and says so, once, in the room (the author
21
+ // signed it; the person chose the module). A module installed by hand is never touched.
22
+
23
+ import { createHash, createPublicKey, verify } from 'node:crypto';
24
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+ import { t } from './i18n.mjs';
27
+ import { MARKET_PUBLIC_KEY } from './market-key.mjs';
28
+
29
+ export const MARKET_URL = 'https://madre.run/mercado/catalogo.json';
30
+ const MAX_CATALOG = 1024 * 1024;
31
+ const MAX_SIGNATURE = 1024;
32
+ const MAX_MODULE = 512 * 1024;
33
+ const FRESH_MS = 10 * 60 * 1000;
34
+ const TIMEOUT_MS = 8000;
35
+ const ID = /^[a-z][a-z0-9-]{1,40}$/;
36
+ const SHA = /^[0-9a-f]{64}$/;
37
+ const COLOR = /^#[0-9a-f]{6}$/i;
38
+
39
+ export const marketUrl = (env = process.env) => env.PULSE_MARKET_URL || MARKET_URL;
40
+ const folder = (stateRoot) => join(stateRoot, 'market');
41
+ const readJson = (file) => readFile(file, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
42
+ async function writeAtomic(file, data) {
43
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
44
+ await writeFile(temp, data);
45
+ await rename(temp, file);
46
+ }
47
+
48
+ export function verifySigned(bytes, signature, publicKeyPem = MARKET_PUBLIC_KEY) {
49
+ try { return verify(null, Buffer.from(bytes), createPublicKey(publicKeyPem), Buffer.from(String(signature ?? '').trim(), 'base64')); } catch { return false; }
50
+ }
51
+ export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
52
+
53
+ // MADRE's version against what a module asks for: «1.0.3» ≥ «1.0.2».
54
+ export function fitsVersion(have, need) {
55
+ if (!need) return true;
56
+ const parts = (value) => String(value).replace(/^v/, '').split(/[.-]/).slice(0, 3).map((part) => Number.parseInt(part, 10) || 0);
57
+ const [a, b] = [parts(have), parts(need)];
58
+ for (let at = 0; at < 3; at += 1) if (a[at] !== b[at]) return a[at] > b[at];
59
+ return true;
60
+ }
61
+
62
+ // Words in both languages, or one for both. Anything else is dropped.
63
+ const words = (value, max = 400) => {
64
+ if (typeof value === 'string') return value.slice(0, max);
65
+ if (!value || typeof value !== 'object') return null;
66
+ const out = {};
67
+ for (const lang of ['en', 'es']) if (typeof value[lang] === 'string') out[lang] = value[lang].slice(0, max);
68
+ return Object.keys(out).length ? out : null;
69
+ };
70
+ // The address a module file may come from: https, on the catalog's own host (a local catalog,
71
+ // for a test, may name its own local files).
72
+ function fileUrl(raw, catalogUrl) {
73
+ let url;
74
+ let base;
75
+ try { url = new URL(raw, catalogUrl); base = new URL(catalogUrl); } catch { return null; }
76
+ if (url.host !== base.host) return null;
77
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname))) return null;
78
+ return url.href;
79
+ }
80
+
81
+ // The catalog, read whole or not at all where it matters (its shape), entry by entry where it
82
+ // does not (an entry MADRE cannot read is left out, so a newer catalog still works here).
83
+ export function readCatalog(text, { url = MARKET_URL } = {}) {
84
+ const data = JSON.parse(text);
85
+ if (data?.schema !== 1 || !Array.isArray(data.modules)) throw new Error(t('This is not a Market catalog MADRE can read.'));
86
+ const issued = Date.parse(data.issued);
87
+ if (!Number.isFinite(issued)) throw new Error(t('The Market catalog does not say when it was made.'));
88
+ const shelves = (Array.isArray(data.shelves) ? data.shelves : []).filter((shelf) => ID.test(shelf?.id ?? '')).map((shelf) => ({ id: shelf.id, title: words(shelf.title, 80), about: words(shelf.about, 200) })).slice(0, 20);
89
+ const modules = [];
90
+ for (const raw of data.modules.slice(0, 200)) {
91
+ if (!ID.test(raw?.id ?? '')) continue;
92
+ const entry = {
93
+ id: raw.id,
94
+ shelf: ID.test(raw.shelf ?? '') ? raw.shelf : null,
95
+ scope: raw.scope === 'project' ? 'project' : 'everywhere',
96
+ price: Number.isFinite(raw.price) && raw.price >= 0 ? raw.price : 0,
97
+ name: words(raw.name, 60),
98
+ line: words(raw.line, 140),
99
+ pitch: words(raw.pitch, 240),
100
+ summary: words(raw.summary, 1200),
101
+ hue: Array.isArray(raw.hue) && raw.hue.length === 2 && raw.hue.every((one) => COLOR.test(one)) ? raw.hue : null,
102
+ // Drawings are markup: they travel signed, and the page keeps only the shapes it knows (market.js).
103
+ icon: typeof raw.icon === 'string' && raw.icon.length <= 8000 ? raw.icon : null,
104
+ art: typeof raw.art === 'string' && raw.art.length <= 60000 ? raw.art : null,
105
+ version: typeof raw.version === 'string' && /^\d+\.\d+\.\d+$/.test(raw.version) ? raw.version : null,
106
+ minMadre: typeof raw.minMadre === 'string' && /^\d+\.\d+\.\d+$/.test(raw.minMadre) ? raw.minMadre : null,
107
+ changes: words(raw.changes, 400),
108
+ file: null,
109
+ };
110
+ if (raw.file) {
111
+ const href = fileUrl(raw.file.url, url);
112
+ if (!href || !SHA.test(raw.file.sha256 ?? '') || !entry.version) continue;
113
+ entry.file = { url: href, sha256: raw.file.sha256, size: Number(raw.file.size) || null };
114
+ }
115
+ modules.push(entry);
116
+ }
117
+ const featured = (Array.isArray(data.featured) ? data.featured : []).filter((id) => modules.some((one) => one.id === id)).slice(0, 8);
118
+ // What the author has to say to everyone who opens MADRE: one line each, said once, and the
119
+ // module it is about when there is one.
120
+ const news = (Array.isArray(data.news) ? data.news : []).filter((one) => ID.test(one?.id ?? '')).map((one) => ({ id: one.id, text: words(one.text, 240), module: ID.test(one.module ?? '') ? one.module : null })).filter((one) => one.text).slice(0, 10);
121
+ return { schema: 1, issued: new Date(issued).toISOString(), shelves, featured, modules, news };
122
+ }
123
+
124
+ async function bytesFrom(url, { fetchImpl, max }) {
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
127
+ try {
128
+ const response = await fetchImpl(url, { signal: controller.signal, headers: { accept: 'application/json, text/javascript, */*' } });
129
+ if (!response.ok) throw new Error(`${response.status}`);
130
+ const buffer = Buffer.from(await response.arrayBuffer());
131
+ if (buffer.length > max) throw new Error('too large');
132
+ return buffer;
133
+ } finally { clearTimeout(timer); }
134
+ }
135
+
136
+ // The catalog kept on this computer, checked again before it is believed.
137
+ export async function keptCatalog({ stateRoot, publicKey = MARKET_PUBLIC_KEY } = {}) {
138
+ const [bytes, signature, state] = await Promise.all([readFile(join(folder(stateRoot), 'catalogo.json')).catch(() => null), readFile(join(folder(stateRoot), 'catalogo.json.sig'), 'utf8').catch(() => null), readJson(join(folder(stateRoot), 'state.json'))]);
139
+ if (!bytes || !signature || !verifySigned(bytes, signature, publicKey)) return null;
140
+ try { return readCatalog(bytes.toString('utf8'), { url: state?.url ?? MARKET_URL }); } catch { return null; }
141
+ }
142
+
143
+ // Ask madre.run for the catalog. Answers with the newest one this computer can trust, and why it
144
+ // is not newer when it could not be.
145
+ export async function refreshCatalog({ stateRoot, env = process.env, fetchImpl = globalThis.fetch, publicKey = MARKET_PUBLIC_KEY, force = false, now = Date.now } = {}) {
146
+ const dir = folder(stateRoot);
147
+ const state = (await readJson(join(dir, 'state.json'))) ?? {};
148
+ const kept = await keptCatalog({ stateRoot, publicKey });
149
+ if (!force && kept && state.checkedAt && now() - Date.parse(state.checkedAt) < FRESH_MS) return { catalog: kept, fresh: false };
150
+ const url = marketUrl(env);
151
+ try {
152
+ const [bytes, signature] = await Promise.all([bytesFrom(url, { fetchImpl, max: MAX_CATALOG }), bytesFrom(`${url}.sig`, { fetchImpl, max: MAX_SIGNATURE })]);
153
+ if (!verifySigned(bytes, signature.toString('utf8'), publicKey)) throw new Error(t('The Market catalog is not signed with MADRE\'s key, so MADRE did not read it.'));
154
+ const catalog = readCatalog(bytes.toString('utf8'), { url });
155
+ if (kept && Date.parse(catalog.issued) < Date.parse(kept.issued)) throw new Error(t('The Market served a catalog older than the one this computer has, so MADRE kept its own.'));
156
+ await mkdir(dir, { recursive: true });
157
+ await writeAtomic(join(dir, 'catalogo.json'), bytes);
158
+ await writeAtomic(join(dir, 'catalogo.json.sig'), signature);
159
+ await writeAtomic(join(dir, 'state.json'), JSON.stringify({ url, checkedAt: new Date(now()).toISOString(), issued: catalog.issued }, null, 2));
160
+ return { catalog, fresh: true };
161
+ } catch (error) {
162
+ await mkdir(dir, { recursive: true }).catch(() => {});
163
+ await writeAtomic(join(dir, 'state.json'), JSON.stringify({ ...state, url: state.url ?? url, checkedAt: new Date(now()).toISOString(), failed: error.name === 'AbortError' ? 'timeout' : String(error.message).slice(0, 200) }, null, 2)).catch(() => {});
164
+ return { catalog: kept, fresh: false, error: error.name === 'AbortError' ? 'timeout' : error.message };
165
+ }
166
+ }
167
+
168
+ export const installedFromMarket = async (stateRoot) => (await readJson(join(folder(stateRoot), 'installed.json'))) ?? {};
169
+
170
+ // One module from the catalog onto this computer, for every project (each one turns it on).
171
+ // `verify` and `install` are src/modules/index.mjs's own checks and writer.
172
+ export async function installFromMarket({ stateRoot, projectRoot, id, catalog, madre, fetchImpl = globalThis.fetch, verifyText, installText }) {
173
+ const entry = catalog?.modules.find((one) => one.id === id && one.file);
174
+ if (!entry) throw new Error(t('The Market has no module «{id}» to install.', { id }));
175
+ if (!fitsVersion(madre, entry.minMadre)) throw new Error(t('This module needs MADRE {version} or newer.', { version: entry.minMadre }));
176
+ const bytes = await bytesFrom(entry.file.url, { fetchImpl, max: MAX_MODULE });
177
+ if (sha256(bytes) !== entry.file.sha256) throw new Error(t('The module file is not the one the Market signed, so MADRE did not install it.'));
178
+ const text = bytes.toString('utf8');
179
+ const checked = await verifyText({ text, name: `${id}.mjs`, replace: true });
180
+ if (checked.id !== id) throw new Error(t('The file says it is «{found}», not «{id}», so MADRE did not install it.', { found: checked.id, id }));
181
+ const done = await installText({ text, name: `${id}.mjs`, scope: 'user', stateRoot, projectRoot, source: { kind: 'market', from: entry.file.url }, replace: true });
182
+ const dir = folder(stateRoot);
183
+ await mkdir(dir, { recursive: true });
184
+ const installed = await installedFromMarket(stateRoot);
185
+ installed[id] = { version: entry.version, sha256: entry.file.sha256, at: new Date().toISOString() };
186
+ await writeAtomic(join(dir, 'installed.json'), JSON.stringify(installed, null, 2));
187
+ return { ...done, version: entry.version };
188
+ }
189
+
190
+ // A module removed by hand is no longer the Market's to update: it is forgotten here too, or the
191
+ // next catalog would bring it back.
192
+ export async function forgetFromMarket(stateRoot, id) {
193
+ const installed = await installedFromMarket(stateRoot);
194
+ if (!installed[id]) return false;
195
+ delete installed[id];
196
+ await writeAtomic(join(folder(stateRoot), 'installed.json'), JSON.stringify(installed, null, 2));
197
+ return true;
198
+ }
199
+
200
+ // THE MARKET'S NEWS, told once to each computer: what the author wrote in the catalog, and every
201
+ // module that arrived in it that this computer does not have. Kept in market/announced.json.
202
+ export async function marketAnnouncements(stateRoot, catalog, have = []) {
203
+ if (!catalog) return { news: [], modules: [] };
204
+ const told = new Set((await readJson(join(folder(stateRoot), 'announced.json'))) ?? []);
205
+ return {
206
+ news: (catalog.news ?? []).filter((one) => !told.has(`news:${one.id}`)),
207
+ modules: catalog.modules.filter((one) => one.file && !have.includes(one.id) && !told.has(`module:${one.id}`)).map(({ id, name, line }) => ({ id, name, line })),
208
+ };
209
+ }
210
+ export async function marketAnnounced(stateRoot, keys = []) {
211
+ const told = new Set((await readJson(join(folder(stateRoot), 'announced.json'))) ?? []);
212
+ for (const key of keys) if (/^(news|module):[a-z][a-z0-9-]{1,40}$/.test(String(key))) told.add(key);
213
+ await mkdir(folder(stateRoot), { recursive: true });
214
+ await writeAtomic(join(folder(stateRoot), 'announced.json'), JSON.stringify([...told].slice(-500)));
215
+ }
216
+
217
+ // What the Market updated on its own and the person has not been told yet.
218
+ export const marketNews = async (stateRoot) => (await readJson(join(folder(stateRoot), 'updates.json'))) ?? [];
219
+ export async function marketNewsSeen(stateRoot) { await writeAtomic(join(folder(stateRoot), 'updates.json'), '[]').catch(() => {}); }
220
+
221
+ // Every module got from the Market whose signed file changed: the new one, installed, and a line
222
+ // for the person. One that fails stays as it was and is tried again next time.
223
+ export async function updateFromMarket({ stateRoot, projectRoot, catalog, madre, fetchImpl = globalThis.fetch, verifyText, installText, names = {} }) {
224
+ const installed = await installedFromMarket(stateRoot);
225
+ const updated = [];
226
+ for (const [id, have] of Object.entries(installed)) {
227
+ const entry = catalog?.modules.find((one) => one.id === id && one.file);
228
+ if (!entry || entry.file.sha256 === have.sha256 || !fitsVersion(madre, entry.minMadre)) continue;
229
+ try {
230
+ await installFromMarket({ stateRoot, projectRoot, id, catalog, madre, fetchImpl, verifyText, installText });
231
+ updated.push({ id, name: names[id] ?? (typeof entry.name === 'string' ? entry.name : entry.name?.en) ?? id, from: have.version ?? null, to: entry.version, changes: entry.changes ?? null, at: new Date().toISOString() });
232
+ } catch { /* tried again the next time MADRE opens */ }
233
+ }
234
+ if (updated.length) {
235
+ const news = [...(await marketNews(stateRoot)).filter((one) => !updated.some((fresh) => fresh.id === one.id)), ...updated];
236
+ await writeAtomic(join(folder(stateRoot), 'updates.json'), JSON.stringify(news, null, 2));
237
+ }
238
+ return updated;
239
+ }
package/src/maturity.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
  // How grown a room is, and how far from being worth training on.
2
4
  //
3
5
  // Pure functions. The room hands in what it has; nothing here reads a file or calls a model.
@@ -45,7 +47,7 @@ export function maturity({ readiness = null, notes = [], links = [], stats = nul
45
47
  next: t('Memories nobody has needed may be noise, or may simply not have come up yet. Ask the project about older decisions and see which ones answer.'),
46
48
  },
47
49
  {
48
- id: 'weave', label: t('HOW WOVEN IT IS'),
50
+ id: 'weave', label: t('HOW CONNECTED IT IS'),
49
51
  value: round(share(connected, standing.length)),
50
52
  detail: t('{connected} of {total} memories share a subject with another', { connected, total: standing.length }),
51
53
  next: t('An archive of unrelated notes is a list. Depth comes from returning to the same subjects.'),