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