atris 3.58.8 → 3.60.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.
Files changed (43) hide show
  1. package/AGENTS.md +3 -2
  2. package/atris/skills/engines/SKILL.md +28 -1
  3. package/bin/atris.js +13 -0
  4. package/commands/autopilot.js +75 -5
  5. package/commands/clean.js +17 -0
  6. package/commands/doc-health.js +11 -6
  7. package/commands/engine.js +386 -4
  8. package/commands/host.js +62 -0
  9. package/commands/member.js +57 -6
  10. package/commands/mission.js +72 -8
  11. package/commands/one-lap.js +58 -3
  12. package/commands/run.js +4 -1
  13. package/commands/scout.js +1 -1
  14. package/commands/search.js +5 -3
  15. package/commands/team.js +20 -3
  16. package/commands/unknowns.js +5 -0
  17. package/commands/verify.js +12 -4
  18. package/commands/visualize.js +20 -3
  19. package/commands/workflow.js +17 -6
  20. package/lib/dispatch-scout.js +31 -22
  21. package/lib/engine-registry.js +1482 -13
  22. package/lib/feedback-triage.js +2 -0
  23. package/lib/fleet.js +107 -11
  24. package/lib/host.js +461 -0
  25. package/lib/intents.js +15 -0
  26. package/lib/known-commands.js +1 -1
  27. package/lib/map-refs.js +44 -1
  28. package/lib/member-engine.js +253 -0
  29. package/lib/roster-markdown.js +206 -0
  30. package/lib/roster-models.js +179 -0
  31. package/lib/runner-command.js +94 -14
  32. package/lib/self-drive.js +9 -1
  33. package/lib/team-lineup.js +167 -0
  34. package/lib/wish-audit.js +10 -6
  35. package/lib/wish-delegate.js +10 -0
  36. package/package.json +1 -1
  37. package/templates/members/host/MEMBER.md +23 -0
  38. package/templates/members/host/MISSION.md +11 -0
  39. package/templates/members/host/SOUL.md +7 -0
  40. package/templates/members/host/context/why.md +3 -0
  41. package/templates/members/host/goals.json +5 -0
  42. package/templates/members/host/goals.md +3 -0
  43. package/templates/members/host/skills/host/SKILL.md +15 -0
package/lib/host.js ADDED
@@ -0,0 +1,461 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+
7
+ const DEFAULT_CONFIG = Object.freeze({ cadence_days: 3, question_expiry_days: 7, pause_after_unanswered: 2, intro_expiry_days: 7, intros_per_person_per_30d: 1, newcomer_intros_first_30d: 3, followup_days: 14, nudge_days: 3, rest_days: 21 });
8
+ const PERSON_KEYS = ['schema', 'id', 'name', 'team', 'manager_id', 'door', 'status', 'joined_at', 'told_host_sees_at', 'next_question_at', 'pending_question_id', 'pending_question', 'pending_expires_at', 'unanswered_count', 'revision', 'processed_events', 'rest_until'];
9
+ const INTRO_KEYS = ['a', 'b', 'attempt_id', 'reason', 'activity', 'version', 'a_said', 'b_said', 'state', 'created_at', 'expires_at', 'closed_reason', 'introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for'];
10
+ const NEW_INTRO_KEYS = ['introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for'];
11
+ const CARD_KEYS = ['into_lately', 'going_for', 'great_at', 'wants_to_meet', 'worth_celebrating'];
12
+ const CARD_TITLES = ['Into lately', 'Going for', 'Great at', 'Wants to meet', 'Worth celebrating'];
13
+
14
+ function fail(message) { throw new Error(message); }
15
+ function required(value, label) { if (typeof value !== 'string' || !value.trim()) fail(`${label} is required`); return value.trim(); }
16
+ function singleLine(value, label) { const text = required(value, label); if (/[\r\n]/.test(text)) fail(`${label} must be one line`); return text; }
17
+ function providerRef(value) { const ref = singleLine(value, 'ref'); if (ref.length > 200) fail('ref must be at most 200 characters'); return ref; }
18
+ function safeId(value) { const id = required(value, 'id'); if (!/^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$/.test(id)) fail('id must use letters, numbers, colon, underscore, or dash'); return id; }
19
+ function fileId(id) { return encodeURIComponent(safeId(id)); }
20
+ function days(iso, count) { return new Date(Date.parse(iso) + count * 86400000).toISOString(); }
21
+ function nowIso(now) { const date = new Date(now || Date.now()); if (!Number.isFinite(date.getTime())) fail('invalid time'); return date.toISOString(); }
22
+ function privateDir(root) { if (!fs.existsSync(path.join(root, 'atris'))) fail('run from a workspace containing atris/'); return path.join(root, 'atris', 'team', 'host', 'private'); }
23
+ function personPath(root, id) { return path.join(privateDir(root), 'people', `${fileId(id)}.md`); }
24
+ function cardPath(root, id) { return path.join(root, 'atris', 'wiki', 'people', `${fileId(id)}.md`); }
25
+ function introDir(root) { return path.join(privateDir(root), 'intros'); }
26
+ function outboxDir(root) { return path.join(privateDir(root), 'outbox'); }
27
+ function readText(file, fallback = null) { if (!fs.existsSync(file)) return fallback; return fs.readFileSync(file, 'utf8'); }
28
+ function atomic(file, content) {
29
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
+ const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
31
+ try { fs.writeFileSync(temp, content, { flag: 'wx' }); fs.renameSync(temp, file); }
32
+ finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
33
+ }
34
+ function json(file, fallback = null) {
35
+ const raw = readText(file);
36
+ if (raw === null) return fallback;
37
+ try { return JSON.parse(raw); } catch { fail(`malformed json: ${file}`); }
38
+ }
39
+ function putJson(file, value) { atomic(file, `${JSON.stringify(value, null, 2)}\n`); }
40
+ function rows(file) {
41
+ const raw = readText(file, '');
42
+ return raw.split('\n').filter(Boolean).map((line) => {
43
+ try { return JSON.parse(line); } catch { fail(`malformed jsonl: ${file}`); }
44
+ });
45
+ }
46
+ function putRows(file, entries) { atomic(file, entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '')); }
47
+ function record(file, keys, optional = []) {
48
+ const raw = readText(file);
49
+ if (raw === null) fail(`missing record: ${file}`);
50
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
51
+ if (!match) fail(`malformed record: ${file}`);
52
+ const data = {};
53
+ for (const line of match[1].split('\n')) {
54
+ const part = line.match(/^([a-z_]+): (.+)$/);
55
+ if (!part || !keys.includes(part[1]) || Object.hasOwn(data, part[1])) fail(`malformed record: ${file}`);
56
+ try { data[part[1]] = JSON.parse(part[2]); } catch { fail(`malformed record: ${file}`); }
57
+ }
58
+ if (keys.some((key) => !Object.hasOwn(data, key) && !optional.includes(key))) fail(`malformed record: ${file}`);
59
+ for (const key of optional) if (!Object.hasOwn(data, key)) data[key] = null;
60
+ return { data, body: match[2] };
61
+ }
62
+ function putRecord(file, keys, data, body = '') {
63
+ atomic(file, `---\n${keys.map((key) => `${key}: ${JSON.stringify(data[key])}`).join('\n')}\n---\n${body}`);
64
+ }
65
+ function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
66
+ function locked(root, work) {
67
+ const dir = privateDir(root);
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ const lock = path.join(dir, '.lock');
70
+ const deadline = Date.now() + 10000;
71
+ while (true) {
72
+ try { fs.mkdirSync(lock); break; }
73
+ catch (error) {
74
+ if (error.code !== 'EEXIST') throw error;
75
+ try { if (Date.now() - fs.statSync(lock).mtimeMs > 600000) fs.rmSync(lock, { recursive: true, force: true }); }
76
+ catch (statError) { if (statError.code !== 'ENOENT') throw statError; }
77
+ if (Date.now() >= deadline) fail('host is busy; retry');
78
+ sleep(20 + Math.floor(Math.random() * 50));
79
+ }
80
+ }
81
+ try { return work(); } finally { fs.rmSync(lock, { recursive: true, force: true }); }
82
+ }
83
+ function config(root) {
84
+ const file = path.join(privateDir(root), 'config.json');
85
+ if (!fs.existsSync(file)) putJson(file, DEFAULT_CONFIG);
86
+ const value = json(file);
87
+ if (!value || Object.keys(DEFAULT_CONFIG).some((key) => {
88
+ const setting = Object.hasOwn(value, key) ? value[key] : DEFAULT_CONFIG[key];
89
+ return !Number.isInteger(setting) || setting < 1;
90
+ })) fail(`malformed config: ${file}`);
91
+ return { ...DEFAULT_CONFIG, ...value };
92
+ }
93
+ function people(root) {
94
+ const dir = path.join(privateDir(root), 'people');
95
+ if (!fs.existsSync(dir)) return [];
96
+ return fs.readdirSync(dir).filter((name) => name.endsWith('.md')).map((name) => record(path.join(dir, name), PERSON_KEYS, ['rest_until'])).sort((a, b) => a.data.name.localeCompare(b.data.name));
97
+ }
98
+ function person(root, id) { return record(personPath(root, id), PERSON_KEYS, ['rest_until']); }
99
+ function savePerson(root, entry) { entry.data.revision += 1; putRecord(personPath(root, entry.data.id), PERSON_KEYS, entry.data, entry.body); }
100
+ function intros(root) {
101
+ const dir = introDir(root);
102
+ if (!fs.existsSync(dir)) return [];
103
+ return fs.readdirSync(dir).filter((name) => name.endsWith('.md')).map((name) => ({ file: path.join(dir, name), ...record(path.join(dir, name), INTRO_KEYS, NEW_INTRO_KEYS) }));
104
+ }
105
+ function queue(root, to, kind, text, at, attemptId = null, questionId = null) {
106
+ const recipient = person(root, to).data;
107
+ const message = { id: crypto.randomUUID(), to, door: recipient.door, kind, text, created_at: at, state: 'queued' };
108
+ if (attemptId) message.attempt_id = attemptId;
109
+ if (questionId) message.question_id = questionId;
110
+ putJson(path.join(outboxDir(root), `${message.id}.json`), message);
111
+ return message;
112
+ }
113
+ function messages(root) {
114
+ const dir = outboxDir(root);
115
+ if (!fs.existsSync(dir)) return [];
116
+ return fs.readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => json(path.join(dir, name))).sort((a, b) => a.created_at.localeCompare(b.created_at));
117
+ }
118
+ function dropQueued(root, predicate) {
119
+ for (const message of messages(root)) if (message.state === 'queued' && predicate(message)) fs.unlinkSync(path.join(outboxDir(root), `${message.id}.json`));
120
+ }
121
+ function closeIntro(root, entry, reason) {
122
+ entry.data.state = 'closed';
123
+ entry.data.closed_reason = reason;
124
+ putRecord(entry.file, INTRO_KEYS, entry.data, entry.body);
125
+ dropQueued(root, (message) => message.kind === 'intro_ask' && message.attempt_id === entry.data.attempt_id);
126
+ }
127
+ function closeFor(root, id, reason) {
128
+ for (const entry of intros(root)) if (entry.data.state === 'pending' && [entry.data.a, entry.data.b].includes(id)) closeIntro(root, entry, reason);
129
+ }
130
+ function expireIntros(root, at) {
131
+ for (const entry of intros(root)) if (entry.data.state === 'pending' && entry.data.expires_at <= at) closeIntro(root, entry, 'expired');
132
+ }
133
+ function expireQuestions(root, at, settings) {
134
+ for (const entry of people(root)) {
135
+ const p = entry.data;
136
+ if (p.status !== 'active' || !p.pending_question_id || p.pending_expires_at > at) continue;
137
+ p.pending_question_id = null; p.pending_question = null; p.pending_expires_at = null;
138
+ p.unanswered_count += 1;
139
+ dropQueued(root, (message) => message.to === p.id && message.kind === 'question');
140
+ if (p.unanswered_count >= settings.pause_after_unanswered) {
141
+ p.status = 'paused';
142
+ p.rest_until = days(at, settings.rest_days);
143
+ closeFor(root, p.id, 'pause');
144
+ dropQueued(root, (message) => message.to === p.id);
145
+ }
146
+ savePerson(root, entry);
147
+ }
148
+ }
149
+ function resumeResting(root, at) {
150
+ for (const entry of people(root)) {
151
+ const p = entry.data;
152
+ if (p.status !== 'paused' || !p.rest_until || p.rest_until > at) continue;
153
+ p.status = 'active'; p.unanswered_count = 0; p.next_question_at = at; p.rest_until = null;
154
+ savePerson(root, entry);
155
+ }
156
+ }
157
+ function queueNudges(root, at) {
158
+ for (const entry of intros(root)) {
159
+ const intro = entry.data;
160
+ if (intro.state !== 'introduced' || !intro.nudge_at || intro.nudge_sent || intro.followup_sent || intro.schedule_requested || intro.scheduled_for || intro.nudge_at > at) continue;
161
+ for (const [id, other] of [[intro.a, intro.b], [intro.b, intro.a]]) {
162
+ if (person(root, id).data.status !== 'active') continue;
163
+ queue(root, id, 'nudge', `Want me to find a time for you and ${person(root, other).data.name} to do ${intro.activity}? Reply yes or no (no if you already have one).`, at, intro.attempt_id);
164
+ }
165
+ intro.nudge_sent = at;
166
+ putRecord(entry.file, INTRO_KEYS, intro, entry.body);
167
+ }
168
+ }
169
+ function queueFollowups(root, at) {
170
+ for (const entry of intros(root)) {
171
+ const intro = entry.data;
172
+ if (intro.state !== 'introduced' || !intro.followup_at || intro.followup_sent || intro.followup_at > at) continue;
173
+ for (const [id, other] of [[intro.a, intro.b], [intro.b, intro.a]]) {
174
+ if (person(root, id).data.status !== 'active') continue;
175
+ queue(root, id, 'followup', `Did you and ${person(root, other).data.name} end up doing ${intro.activity}? Worth doing again? Reply yes or no.`, at, intro.attempt_id);
176
+ }
177
+ intro.followup_sent = at;
178
+ putRecord(entry.file, INTRO_KEYS, intro, entry.body);
179
+ }
180
+ }
181
+ function choice(answer) {
182
+ const first = answer.toLowerCase().match(/[\p{L}\p{N}]+/u)?.[0];
183
+ if (['yes', 'y', 'yeah', 'yep', 'yup', 'sure', 'absolutely', 'definitely', 'ok', 'okay', 'down', 'totally', 'love'].includes(first)) return 'yes';
184
+ if (['no', 'n', 'nah', 'nope', 'pass'].includes(first)) return 'no';
185
+ return null;
186
+ }
187
+ function awaiting(intro, id) {
188
+ return intro.data.state === 'pending' && [intro.data.a, intro.data.b].includes(id) && intro.data[intro.data.a === id ? 'a_said' : 'b_said'] === null;
189
+ }
190
+ function cardText(name, team, values) {
191
+ return `---\nname: ${JSON.stringify(name)}\nteam: ${JSON.stringify(team)}\n---\n\n# ${name}\n\n${CARD_KEYS.map((key, index) => `## ${CARD_TITLES[index]}\n\n${values[key] || ''}`).join('\n\n')}\n`;
192
+ }
193
+ function cardValues(raw) {
194
+ const values = {};
195
+ for (let i = 0; i < CARD_KEYS.length; i += 1) {
196
+ const start = raw.indexOf(`## ${CARD_TITLES[i]}\n\n`);
197
+ if (start < 0) fail('malformed published card');
198
+ const begin = start + `## ${CARD_TITLES[i]}\n\n`.length;
199
+ const end = raw.indexOf('\n\n## ', begin);
200
+ values[CARD_KEYS[i]] = raw.slice(begin, end < 0 ? undefined : end).trim();
201
+ }
202
+ return values;
203
+ }
204
+ function published(root, id) { const raw = readText(cardPath(root, id)); if (raw === null) fail(`missing published card for ${id}`); return raw; }
205
+ // Activities are read mid-sentence ("find a time to do ..."), so drop end punctuation and a leading capital article.
206
+ function phrase(text) { return text.replace(/[\s.!?]+$/, '').replace(/^(A|An|The|One)\b/, (word) => word.toLowerCase()); }
207
+ function pair(a, b) { return [safeId(a), safeId(b)].sort(); }
208
+ function introLimit(person, at, settings) {
209
+ const joined = Date.parse(person.joined_at), current = Date.parse(at);
210
+ return joined <= current && joined >= current - 30 * 86400000 ? settings.newcomer_intros_first_30d : settings.intros_per_person_per_30d;
211
+ }
212
+
213
+ function hostAction(root, command, arg = {}) {
214
+ if (command === 'receive' && arg.decision != null && !['yes', 'no'].includes(arg.decision)) fail('decision must be yes or no');
215
+ if (command === 'receive' && arg.replyTo != null && arg.replyToRef != null) fail('choose --reply-to or --reply-to-ref');
216
+ if (command === 'view') {
217
+ if (arg.as && arg.team) fail('choose --as or --team');
218
+ if (arg.as) {
219
+ const own = person(root, arg.as);
220
+ return { cards: people(root).filter((entry) => entry.data.status !== 'left').map((entry) => ({ id: entry.data.id, card: published(root, entry.data.id) })), own: { ...own.data, body: own.body } };
221
+ }
222
+ if (arg.team) return { cards: people(root).filter((entry) => entry.data.status !== 'left' && entry.data.team === arg.team).map((entry) => ({ id: entry.data.id, card: published(root, entry.data.id) })) };
223
+ fail('view needs --as <id> or --team <team>');
224
+ }
225
+ return locked(root, () => {
226
+ const settings = config(root);
227
+ const at = nowIso(arg.now);
228
+ expireIntros(root, at);
229
+ expireQuestions(root, at, settings);
230
+ resumeResting(root, at);
231
+ queueNudges(root, at);
232
+ queueFollowups(root, at);
233
+ if (command === 'outbox') return messages(root).filter((message) => message.state === 'queued');
234
+ if (command === 'schedule') return intros(root).filter((entry) => entry.data.state === 'introduced' && entry.data.schedule_requested && !entry.data.scheduled_for).map(({ data }) => ({
235
+ attempt_id: data.attempt_id, a: data.a, b: data.b,
236
+ names: { a: person(root, data.a).data.name, b: person(root, data.b).data.name }, activity: data.activity,
237
+ }));
238
+ if (command === 'scheduled') {
239
+ const attempt = required(arg.id, 'attempt id');
240
+ const entry = intros(root).find((intro) => intro.data.attempt_id === attempt && intro.data.state === 'introduced');
241
+ if (!entry) fail('unknown introduced attempt');
242
+ if (entry.data.scheduled_for) fail('introduction already scheduled');
243
+ const when = singleLine(arg.when, 'when');
244
+ entry.data.scheduled_for = when;
245
+ entry.data.schedule_requested = false;
246
+ putRecord(entry.file, INTRO_KEYS, entry.data, entry.body);
247
+ for (const [id, other] of [[entry.data.a, entry.data.b], [entry.data.b, entry.data.a]]) {
248
+ queue(root, id, 'booked', `Booked: ${entry.data.activity} with ${person(root, other).data.name}, ${when}. Have fun.`, at, attempt);
249
+ }
250
+ return { attempt_id: attempt, scheduled_for: when };
251
+ }
252
+ if (command === 'people') {
253
+ const history = intros(root);
254
+ const recent = history.filter((entry) => Date.parse(entry.data.created_at) > Date.parse(at) - 30 * 86400000);
255
+ return people(root).filter((entry) => entry.data.status === 'active').map((entry) => ({
256
+ id: entry.data.id, name: entry.data.name, team: entry.data.team, manager_id: entry.data.manager_id, status: entry.data.status,
257
+ can_be_introduced: !history.some((intro) => ['pending', 'ready'].includes(intro.data.state) && [intro.data.a, intro.data.b].includes(entry.data.id)) && recent.filter((intro) => [intro.data.a, intro.data.b].includes(entry.data.id)).length < introLimit(entry.data, at, settings),
258
+ ...cardValues(published(root, entry.data.id)),
259
+ }));
260
+ }
261
+ if (command === 'join') {
262
+ const id = safeId(arg.id);
263
+ if (rows(path.join(privateDir(root), 'forgotten.jsonl')).some((row) => row.id === id)) fail('this id was forgotten and cannot rejoin');
264
+ if (fs.existsSync(personPath(root, id))) fail('person already joined');
265
+ const name = singleLine(arg.name, 'name');
266
+ const data = { schema: 1, id, name, team: arg.team ? singleLine(arg.team, 'team') : '', manager_id: arg.manager || null, door: arg.door ? singleLine(arg.door, 'door') : id, status: 'active', joined_at: at, told_host_sees_at: at, next_question_at: at, pending_question_id: null, pending_question: null, pending_expires_at: null, unanswered_count: 0, revision: 1, processed_events: [], rest_until: null };
267
+ if (data.manager_id) safeId(data.manager_id);
268
+ putRecord(personPath(root, id), PERSON_KEYS, data, '');
269
+ if (!fs.existsSync(cardPath(root, id))) atomic(cardPath(root, id), cardText(name, data.team, {}));
270
+ queue(root, id, 'welcome', `Hi ${name}. I am the Host. I will ask a fun question now and then to get to know you. The human host can see your answers. You can pause or leave anytime.`, at);
271
+ return { id, revision: 1, due: true };
272
+ }
273
+ if (['leave', 'pause', 'resume'].includes(command)) {
274
+ const entry = person(root, arg.id);
275
+ entry.data.status = command === 'resume' ? 'active' : command === 'leave' ? 'left' : 'paused';
276
+ entry.data.rest_until = null;
277
+ if (command !== 'resume') { closeFor(root, arg.id, command); dropQueued(root, (message) => message.to === arg.id); }
278
+ if (command === 'resume') { entry.data.next_question_at = at; entry.data.unanswered_count = 0; }
279
+ savePerson(root, entry);
280
+ return { id: arg.id, status: entry.data.status };
281
+ }
282
+ if (command === 'forget') {
283
+ const id = safeId(arg.id);
284
+ person(root, id);
285
+ for (const entry of people(root)) if (entry.data.id !== id && entry.data.manager_id === id) { entry.data.manager_id = null; savePerson(root, entry); }
286
+ const affected = intros(root).filter((entry) => [entry.data.a, entry.data.b].includes(id));
287
+ for (const entry of affected) fs.unlinkSync(entry.file);
288
+ const attempts = new Set(affected.map((entry) => entry.data.attempt_id));
289
+ for (const message of messages(root)) if (message.to === id || attempts.has(message.attempt_id)) fs.unlinkSync(path.join(outboxDir(root), `${message.id}.json`));
290
+ const linkFile = path.join(privateDir(root), 'links.jsonl');
291
+ putRows(linkFile, rows(linkFile).filter((link) => link.a !== id && link.b !== id));
292
+ fs.unlinkSync(personPath(root, id));
293
+ if (fs.existsSync(cardPath(root, id))) fs.unlinkSync(cardPath(root, id));
294
+ const marker = path.join(privateDir(root), 'forgotten.jsonl');
295
+ putRows(marker, [...rows(marker), { id }]);
296
+ // The prior room summary may name this person.
297
+ const room = path.join(privateDir(root), 'room.md');
298
+ if (fs.existsSync(room)) fs.unlinkSync(room);
299
+ return { id, forgotten: true };
300
+ }
301
+ if (command === 'due') {
302
+ const open = intros(root);
303
+ return people(root).filter((entry) => entry.data.status === 'active' && !entry.data.pending_question_id && entry.data.next_question_at <= at && !open.some((intro) => awaiting(intro, entry.data.id))).map((entry) => ({ id: entry.data.id, name: entry.data.name, revision: entry.data.revision, card: published(root, entry.data.id) }));
304
+ }
305
+ if (command === 'ask') {
306
+ const entry = person(root, arg.id);
307
+ if (entry.data.status !== 'active' || entry.data.pending_question_id) fail('person is not ready for a question');
308
+ if (entry.data.next_question_at > at) fail('question is not due yet');
309
+ const question = required(arg.question, 'question');
310
+ entry.data.pending_question_id = crypto.randomUUID();
311
+ entry.data.pending_question = question;
312
+ entry.data.pending_expires_at = days(at, settings.question_expiry_days);
313
+ entry.data.next_question_at = days(at, settings.cadence_days);
314
+ savePerson(root, entry);
315
+ const message = queue(root, arg.id, 'question', question, at, null, entry.data.pending_question_id);
316
+ return { id: arg.id, question_id: entry.data.pending_question_id, message_id: message.id, revision: entry.data.revision };
317
+ }
318
+ if (command === 'receive') {
319
+ const eventId = required(arg.eventId, 'event id');
320
+ const sender = required(arg.from, 'from');
321
+ const matches = people(root).filter((entry) => entry.data.id === sender || entry.data.door === sender);
322
+ if (matches.length !== 1) fail('unknown or ambiguous sender');
323
+ const entry = matches[0];
324
+ if (entry.data.processed_events.includes(eventId)) return { id: entry.data.id, duplicate: true };
325
+ if (entry.data.status !== 'active') fail('person is not active');
326
+ const answer = required(arg.text, 'text');
327
+ entry.data.processed_events = [...entry.data.processed_events, eventId].slice(-200);
328
+ const history = intros(root);
329
+ const open = history.find((intro) => awaiting(intro, entry.data.id));
330
+ const decision = arg.decision || choice(answer);
331
+ const ref = arg.replyToRef == null ? null : providerRef(arg.replyToRef);
332
+ const reply = arg.replyTo ? messages(root).find((message) => message.id === arg.replyTo)
333
+ : ref ? messages(root).find((message) => message.ref === ref && message.to === entry.data.id) : null;
334
+ const routed = reply && reply.to === entry.data.id ? reply : null;
335
+ const replyTo = arg.replyTo || routed?.id;
336
+ const intro = routed && ['intro_ask', 'clarify'].includes(routed.kind)
337
+ ? history.find((candidate) => candidate.data.attempt_id === routed.attempt_id && awaiting(candidate, entry.data.id)) : !replyTo && decision ? open : null;
338
+ const question = routed ? routed.kind === 'question' && entry.data.pending_question_id && (routed.question_id ? routed.question_id === entry.data.pending_question_id : routed.text === entry.data.pending_question)
339
+ : !replyTo && !intro && !!entry.data.pending_question_id;
340
+ const followup = routed?.kind === 'followup' ? history.find((candidate) => candidate.data.attempt_id === routed.attempt_id && candidate.data.state === 'introduced' && candidate.data.followup_sent && candidate.data[candidate.data.a === entry.data.id ? 'a_met' : 'b_met'] === null) : null;
341
+ const nudges = history.filter((candidate) => candidate.data.state === 'introduced' && candidate.data.nudge_sent && !candidate.data.schedule_requested && !candidate.data.scheduled_for && [candidate.data.a, candidate.data.b].includes(entry.data.id));
342
+ const openNudges = nudges.filter((candidate) => !candidate.data.followup_sent);
343
+ const nudge = routed?.kind === 'nudge' ? nudges.find((candidate) => candidate.data.attempt_id === routed.attempt_id) : !replyTo && decision && openNudges.length === 1 ? openNudges[0] : null;
344
+ let kind = 'note';
345
+ if (nudge && decision) {
346
+ if (decision === 'yes') { nudge.data.schedule_requested = true; putRecord(nudge.file, INTRO_KEYS, nudge.data, nudge.body); }
347
+ kind = 'nudge';
348
+ } else if (intro && decision) {
349
+ intro.data[intro.data.a === entry.data.id ? 'a_said' : 'b_said'] = decision;
350
+ if (decision === 'no') { closeIntro(root, intro, 'declined'); kind = 'intro_closed'; }
351
+ else if (intro.data.a_said === 'yes' && intro.data.b_said === 'yes') {
352
+ intro.data.state = 'ready'; putRecord(intro.file, INTRO_KEYS, intro.data, intro.body);
353
+ queue(root, intro.data.a, 'intro', intro.body, at, intro.data.attempt_id);
354
+ queue(root, intro.data.b, 'intro', intro.body, at, intro.data.attempt_id);
355
+ intro.data.state = 'introduced';
356
+ intro.data.introduced_at = at;
357
+ intro.data.nudge_at = days(at, settings.nudge_days);
358
+ intro.data.followup_at = days(at, settings.followup_days);
359
+ putRecord(intro.file, INTRO_KEYS, intro.data, intro.body);
360
+ const linkFile = path.join(privateDir(root), 'links.jsonl');
361
+ putRows(linkFile, [...rows(linkFile), { a: intro.data.a, b: intro.data.b, source: 'intro', evidence: 'introduced by the Host', at }]);
362
+ kind = 'introduced';
363
+ } else { putRecord(intro.file, INTRO_KEYS, intro.data, intro.body); kind = 'intro_yes'; }
364
+ } else if (question) {
365
+ entry.body += `## ${at}\n\nQ: ${entry.data.pending_question}\n\nA: ${answer}\n\n`;
366
+ entry.data.pending_question_id = null; entry.data.pending_question = null; entry.data.pending_expires_at = null; entry.data.unanswered_count = 0;
367
+ kind = 'answer';
368
+ } else if (followup && decision) {
369
+ followup.data[followup.data.a === entry.data.id ? 'a_met' : 'b_met'] = decision;
370
+ putRecord(followup.file, INTRO_KEYS, followup.data, followup.body);
371
+ if (followup.data.a_met === 'yes' && followup.data.b_met === 'yes') {
372
+ const linkFile = path.join(privateDir(root), 'links.jsonl');
373
+ putRows(linkFile, [...rows(linkFile), { a: followup.data.a, b: followup.data.b, source: 'met', evidence: 'both people confirmed they met', at }]);
374
+ }
375
+ kind = 'followup';
376
+ } else if (open && !question && !decision && (!replyTo || ['intro_ask', 'clarify'].includes(routed?.kind))) {
377
+ const other = open.data.a === entry.data.id ? open.data.b : open.data.a;
378
+ queue(root, entry.data.id, 'clarify', `Quick check on the intro with ${person(root, other).data.name} (${open.data.activity}): would you like it? Reply yes or no.`, at, open.data.attempt_id);
379
+ kind = 'clarify';
380
+ }
381
+ if (kind !== 'answer') entry.body += `## ${at}\n\nNote: ${answer}\n\n`;
382
+ savePerson(root, entry);
383
+ return { id: entry.data.id, kind, revision: entry.data.revision };
384
+ }
385
+ if (command === 'card') {
386
+ const entry = person(root, arg.id);
387
+ if (Number(arg.expectedRevision) !== entry.data.revision) fail('stale card revision');
388
+ const patch = json(path.resolve(root, required(arg.patch, 'patch file')));
389
+ if (!patch || Array.isArray(patch) || typeof patch !== 'object' || Object.keys(patch).some((key) => !CARD_KEYS.includes(key) || typeof patch[key] !== 'string' || /^#{1,6}\s/m.test(patch[key]))) fail('card patch may contain only the five text fields without headings');
390
+ const current = cardValues(published(root, arg.id));
391
+ atomic(cardPath(root, arg.id), cardText(entry.data.name, entry.data.team, { ...current, ...patch }));
392
+ savePerson(root, entry);
393
+ return { id: arg.id, revision: entry.data.revision };
394
+ }
395
+ if (command === 'link') {
396
+ const [a, b] = pair(arg.a, arg.b);
397
+ if (a === b) fail('a person cannot link to themselves');
398
+ person(root, a); person(root, b);
399
+ if (!['channel', 'answer', 'card', 'intro', 'met'].includes(arg.source)) fail('invalid link source');
400
+ const evidence = required(arg.evidence, 'evidence');
401
+ const file = path.join(privateDir(root), 'links.jsonl');
402
+ const links = rows(file);
403
+ if (!links.some((link) => link.a === a && link.b === b && link.source === arg.source && link.evidence === evidence)) links.push({ a, b, source: arg.source, evidence, at });
404
+ putRows(file, links);
405
+ return { a, b, linked: true };
406
+ }
407
+ if (command === 'propose') {
408
+ const [a, b] = pair(arg.a, arg.b);
409
+ if (a === b) fail('cannot introduce a person to themselves');
410
+ const one = person(root, a).data, two = person(root, b).data;
411
+ if (one.status !== 'active' || two.status !== 'active') fail('both people must be active');
412
+ if (one.manager_id === b || two.manager_id === a) fail('manager and report cannot be introduced');
413
+ if (rows(path.join(privateDir(root), 'links.jsonl')).some((link) => link.a === a && link.b === b && link.source !== 'card')) fail('people are already linked');
414
+ const history = intros(root);
415
+ if (history.some((entry) => entry.data.a === a && entry.data.b === b && entry.data.closed_reason === 'declined')) fail('this pair declined before');
416
+ if (history.some((entry) => entry.data.a === a && entry.data.b === b && ['pending', 'ready'].includes(entry.data.state))) fail('this pair already has an introduction in flight');
417
+ if (history.some((entry) => ['pending', 'ready'].includes(entry.data.state) && [a, b].some((id) => [entry.data.a, entry.data.b].includes(id)))) fail('this person already has an introduction in flight');
418
+ const recent = history.filter((entry) => Date.parse(entry.data.created_at) > Date.parse(at) - 30 * 86400000);
419
+ for (const entry of [one, two]) if (recent.filter((intro) => [intro.data.a, intro.data.b].includes(entry.id)).length >= introLimit(entry, at, settings)) fail('introduction cadence reached');
420
+ const reason = required(arg.reason, 'reason'), activity = phrase(required(arg.activity, 'activity')), wording = required(arg.text, 'text');
421
+ const attempt = crypto.randomUUID();
422
+ const data = { a, b, attempt_id: attempt, reason, activity, version: 1, a_said: null, b_said: null, state: 'pending', created_at: at, expires_at: days(at, settings.intro_expiry_days), closed_reason: null, introduced_at: null, followup_at: null, followup_sent: null, a_met: null, b_met: null, nudge_at: null, nudge_sent: null, schedule_requested: null, scheduled_for: null };
423
+ const file = path.join(introDir(root), `${fileId(a)}--${fileId(b)}--${attempt}.md`);
424
+ putRecord(file, INTRO_KEYS, data, wording);
425
+ const ask = `I thought you and ${one.id === a ? two.name : one.name} might enjoy ${activity}. ${reason} Would you like an introduction? Reply yes or no.`;
426
+ queue(root, a, 'intro_ask', ask, at, attempt);
427
+ const otherAsk = `I thought you and ${one.name} might enjoy ${activity}. ${reason} Would you like an introduction? Reply yes or no.`;
428
+ queue(root, b, 'intro_ask', otherAsk, at, attempt);
429
+ return { a, b, attempt_id: attempt, state: 'pending' };
430
+ }
431
+ if (command === 'sent') {
432
+ const id = required(arg.id, 'message id');
433
+ if (!/^[a-f0-9-]{36}$/.test(id)) fail('invalid message id');
434
+ const file = path.join(outboxDir(root), `${id}.json`);
435
+ const message = json(file);
436
+ if (!message) fail('unknown message');
437
+ if (arg.ref != null) message.ref = providerRef(arg.ref);
438
+ message.state = 'sent'; putJson(file, message);
439
+ return { id, state: 'sent' };
440
+ }
441
+ if (command === 'room') {
442
+ const roster = people(root);
443
+ const links = rows(path.join(privateDir(root), 'links.jsonl'));
444
+ const history = intros(root);
445
+ const unconnected = roster.filter((entry) => entry.data.status === 'active' && !links.some((link) => [link.a, link.b].includes(entry.data.id))).map((entry) => entry.data.name);
446
+ const celebrate = roster.filter((entry) => entry.data.status === 'active').flatMap((entry) => { const value = cardValues(published(root, entry.data.id)).worth_celebrating; return value ? [`${entry.data.name}: ${value}`] : []; });
447
+ const flight = history.filter((entry) => ['pending', 'ready'].includes(entry.data.state)).length;
448
+ const happened = history.filter((entry) => entry.data.a_met === 'yes' && entry.data.b_met === 'yes').length;
449
+ const newcomers = roster.filter((entry) => entry.data.status === 'active' && Date.parse(entry.data.joined_at) >= Date.parse(at) - 30 * 86400000);
450
+ const connectedNewcomers = newcomers.filter((entry) => new Set(links.filter((link) => link.source === 'met' && [link.a, link.b].includes(entry.data.id)).map((link) => link.a === entry.data.id ? link.b : link.a)).size >= 2).length;
451
+ const coverage = newcomers.length < 10 ? 'Not enough new people yet to report this (need 10).' : `${connectedNewcomers} of ${newcomers.length} new people have at least two confirmed connections.`;
452
+ const recent = roster.filter((entry) => entry.data.status === 'active' && Date.parse(entry.data.joined_at) >= Date.parse(at) - 7 * 86400000).map((entry) => entry.data.name);
453
+ const content = `# Host room\n\nPeople: ${roster.filter((entry) => entry.data.status === 'active').length} active\n\n## No connections recorded\n\n${unconnected.join('\n') || 'None'}\n\n## Worth celebrating\n\n${celebrate.join('\n') || 'Nothing recorded yet'}\n\n## Introductions in flight\n\n${flight}\n\n## Introductions that happened\n\n${happened}\n\n## New people finding their people\n\n${coverage}\n\n## Suggested welcomes\n\n${recent.join('\n') || 'None'}\n`;
454
+ atomic(path.join(privateDir(root), 'room.md'), content);
455
+ return { text: content };
456
+ }
457
+ fail('unknown host command');
458
+ });
459
+ }
460
+
461
+ module.exports = { hostAction, DEFAULT_CONFIG };
package/lib/intents.js CHANGED
@@ -96,6 +96,21 @@ const INTENTS = Object.freeze([
96
96
  plain: 'your agent shows who is responsible and helps fill any missing role.',
97
97
  offer: 'always',
98
98
  }),
99
+ Object.freeze({
100
+ id: 'show_roster',
101
+ say: Object.freeze(['who does what', 'show me the roster', 'which model builds', 'what model does review', 'show the lineup']),
102
+ do: 'atris engine roster',
103
+ plain: 'your agent shows which tool and model does each job, like building, review, and search.',
104
+ offer: 'always',
105
+ }),
106
+ Object.freeze({
107
+ id: 'change_roster',
108
+ say: Object.freeze(['use x for y', 'change the builder', 'switch the reviewer', 'make codex review']),
109
+ do: 'atris engine assign <job> <tool> --model <model>',
110
+ plain: 'your agent changes which tool and model does a job in the roster file, then shows the new lineup.',
111
+ offer: 'always',
112
+ confirm: true,
113
+ }),
99
114
  Object.freeze({
100
115
  id: 'speak_plainly',
101
116
  say: Object.freeze(['talk plainer', 'too much jargon', 'use plain english', 'keep it simple']),
@@ -5,7 +5,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
- 'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'playbook', 'feed', 'brief', 'social', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
8
+ 'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
11
11
  // adjacent transposition (e.g. "taks" -> "task") as a single edit, since
package/lib/map-refs.js CHANGED
@@ -148,6 +148,10 @@ function checkMapRefs(root, mapText) {
148
148
  const file = linesOf(ref.path);
149
149
  return { ...ref, ...(file ? classify(ref, file) : { status: 'missing-file' }) };
150
150
  });
151
+ return summarize(refs);
152
+ }
153
+
154
+ function summarize(refs) {
151
155
  const count = status => refs.filter(ref => ref.status === status).length;
152
156
  const summary = {
153
157
  total: refs.length, ok: count('ok'), moved: count('moved'), ambiguous: count('ambiguous'),
@@ -180,4 +184,43 @@ function fixMapRefs(root, mapFile = 'atris/MAP.md') {
180
184
  return { changes, left };
181
185
  }
182
186
 
183
- module.exports = { checkMapRefs, fixMapRefs };
187
+ // The short routing map loads at boot; exact line refs live in the notes file.
188
+ // Both are checked and fixed, so moving a ref out of the boot map never hides it.
189
+ const MAP_REF_DOCS = ['atris/MAP.md', 'atris/refs/MAP-NOTES.md'];
190
+
191
+ function existingMapDocs(root) {
192
+ return MAP_REF_DOCS.filter(doc => {
193
+ try { return fs.statSync(path.resolve(root, doc)).isFile(); } catch (error) {
194
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
195
+ throw error;
196
+ }
197
+ });
198
+ }
199
+
200
+ // mapText is the already-read atris/MAP.md, so boot does not read it twice.
201
+ function checkMapDocs(root, mapText) {
202
+ const refs = existingMapDocs(root).flatMap(doc => {
203
+ const text = doc === MAP_REF_DOCS[0] && typeof mapText === 'string'
204
+ ? mapText : fs.readFileSync(path.resolve(root, doc), 'utf8');
205
+ return checkMapRefs(root, text).refs.map(ref => ({ doc, ...ref }));
206
+ });
207
+ return summarize(refs);
208
+ }
209
+
210
+ function fixMapDocs(root) {
211
+ const changes = [];
212
+ const left = [];
213
+ for (const doc of existingMapDocs(root)) {
214
+ const result = fixMapRefs(root, doc);
215
+ changes.push(...result.changes.map(change => ({ doc, ...change })));
216
+ left.push(...result.left.map(ref => ({ doc, ...ref })));
217
+ }
218
+ return { changes, left };
219
+ }
220
+
221
+ // Repo-relative path of the notes file when this workspace has one, else null.
222
+ function mapNotesPath(root) {
223
+ return existingMapDocs(root).includes(MAP_REF_DOCS[1]) ? MAP_REF_DOCS[1] : null;
224
+ }
225
+
226
+ module.exports = { MAP_REF_DOCS, existingMapDocs, mapNotesPath, checkMapRefs, checkMapDocs, fixMapRefs, fixMapDocs };