atris 3.59.0 → 3.61.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/bin/atris.js CHANGED
@@ -1850,6 +1850,16 @@ function showWelcomeVisualization() {
1850
1850
  console.log(row('goal', endgameState.horizon || endgameState.slug));
1851
1851
  }
1852
1852
 
1853
+ // Every new chat sees which tool and model does each job, from the same
1854
+ // resolver as atris engine roster. A roster that cannot be read drops the
1855
+ // line; it never stops the boot.
1856
+ try {
1857
+ const teamLine = require('../lib/team-lineup').bootTeamLine(cwd, { width: 80 - 11 });
1858
+ if (teamLine) console.log(row('team', teamLine));
1859
+ } catch {
1860
+ // Silent: the lineup is a hint, not a gate.
1861
+ }
1862
+
1853
1863
  const hasNoTasks = glance.active === 0
1854
1864
  && glance.review === 0
1855
1865
  && glance.reviewCertified === 0;
@@ -2133,6 +2133,8 @@ module.exports = {
2133
2133
  canonicalEngineName,
2134
2134
  readSavedEngine,
2135
2135
  roster,
2136
+ rosterReport,
2137
+ jobRosterView,
2136
2138
  engineRegistryFile,
2137
2139
  readEngineRegistry,
2138
2140
  resolveEngineForRole,
package/commands/host.js CHANGED
@@ -5,7 +5,7 @@ const { hostAction } = require('../lib/host');
5
5
  function parse(argv) {
6
6
  const options = {};
7
7
  const positionals = [];
8
- const flags = new Set(['id', 'name', 'team', 'manager', 'door', 'now', 'question', 'event-id', 'from', 'text', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as']);
8
+ const flags = new Set(['id', 'name', 'team', 'manager', 'door', 'now', 'started', 'question', 'event-id', 'from', 'text', 'reply-to', 'reply-to-ref', 'ref', 'decision', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as', 'when', 'at']);
9
9
  for (let i = 0; i < argv.length; i += 1) {
10
10
  const item = argv[i];
11
11
  if (item === '--json') { options.json = true; continue; }
@@ -22,6 +22,15 @@ function human(command, result) {
22
22
  if (command === 'room') return result.text;
23
23
  if (command === 'due') return result.length ? result.map((entry) => `${entry.name} (${entry.id}) is due for a question.`).join('\n') : 'no questions due';
24
24
  if (command === 'outbox') return result.length ? result.map((entry) => `${entry.id} ${entry.kind} to ${entry.to}: ${entry.text}`).join('\n') : 'outbox empty';
25
+ if (command === 'schedule') return result.length ? result.map((entry) => `${entry.attempt_id}: ${entry.names.a} (${entry.a}) and ${entry.names.b} (${entry.b}), ${entry.activity}`).join('\n') : 'no introductions need scheduling';
26
+ if (command === 'people') {
27
+ if (!result.length) return 'no active people';
28
+ return result.map((entry) => {
29
+ const fields = ['into_lately', 'going_for', 'great_at', 'wants_to_meet', 'worth_celebrating']
30
+ .map((key) => ` ${key.replaceAll('_', ' ')}: ${entry[key]}`).join('\n');
31
+ return `${entry.name} (${entry.id})\n team: ${entry.team || 'none'}\n manager: ${entry.manager_id || 'none'}\n status: ${entry.status}\n can be introduced: ${entry.can_be_introduced ? 'yes' : 'no'}\n${fields}`;
32
+ }).join('\n');
33
+ }
25
34
  if (command === 'view') return [...result.cards.map((entry) => entry.card), result.own ? `Your private record:\n${JSON.stringify(result.own, null, 2)}` : ''].filter(Boolean).join('\n');
26
35
  if (command === 'receive') return result.duplicate ? 'event already recorded' : `${result.kind} recorded for ${result.id}`;
27
36
  return `${command} complete: ${JSON.stringify(result)}`;
@@ -32,14 +41,14 @@ function hostCommand(argv = process.argv.slice(3), root = process.cwd()) {
32
41
  try {
33
42
  const [command, ...rest] = argv;
34
43
  if (!command || command === '--help' || command === '-h') {
35
- const usage = 'usage: atris host join|leave|pause|resume|forget|due|ask|receive|card|link|propose|outbox|sent|room|view [options]';
44
+ const usage = 'usage: atris host join|leave|pause|resume|forget|due|ask|receive|card|link|propose|people|outbox|sent|room|view|schedule|scheduled [options]';
36
45
  console.log(jsonOutput ? JSON.stringify({ usage }) : usage);
37
46
  return;
38
47
  }
39
48
  const { options, positionals } = parse(rest);
40
49
  jsonOutput = options.json;
41
50
  const args = { ...options };
42
- if (['leave', 'pause', 'resume', 'forget', 'ask', 'card', 'sent'].includes(command)) args.id = positionals[0];
51
+ if (['leave', 'pause', 'resume', 'forget', 'ask', 'card', 'sent', 'scheduled'].includes(command)) args.id = positionals[0];
43
52
  if (['link', 'propose'].includes(command)) { args.a = positionals[0]; args.b = positionals[1]; }
44
53
  if (command === 'receive') args.eventId = options.eventId;
45
54
  const result = hostAction(root, command, args);
package/commands/team.js CHANGED
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const { canonicalEngineName } = require('../lib/engine-registry');
7
7
  const taskDb = require('../lib/task-db');
8
8
  const { buildTeamPresence, DEFAULT_FRESHNESS_WINDOW_MS, renderTeamPresence } = require('../lib/team-presence');
9
+ const { LINEUP_UNREADABLE, memberLineup, readLineupSafe, renderLineup } = require('../lib/team-lineup');
9
10
  const { readEngineRegistry } = require('./engine');
10
11
  const { listMissions, listWorktreeRollupMissions } = require('./mission');
11
12
  const { collectSnapshot, collectStreamEvents, repoRoot } = require('./stream');
@@ -262,6 +263,16 @@ function renderTeamRoster(rosterRows, deps = {}) {
262
263
  return lines.join('\n');
263
264
  }
264
265
 
266
+ // The lineup (who does each job, who is on each job) above today's active
267
+ // and rest lists.
268
+ function renderTeamWithLineup(rosterRows, lineup, deps = {}) {
269
+ const width = Math.min(deps.termWidth || process.stdout.columns || 80, 100);
270
+ const today = renderTeamRoster(rosterRows, deps);
271
+ if (!lineup || !lineup.ok) return `${today}\n\n${LINEUP_UNREADABLE}`;
272
+ const block = renderLineup(lineup, { width });
273
+ return block ? `${block}\n\n${today}` : today;
274
+ }
275
+
265
276
  function renderTeamRosterHtml(rosterRows, meta = {}) {
266
277
  const activeRows = rosterRows.filter((entry) => entry.active);
267
278
  const restRows = rosterRows.filter((entry) => !entry.active);
@@ -485,7 +496,7 @@ function renderTeamPrune(report, days = DEFAULT_PRUNE_DAYS) {
485
496
 
486
497
  function helpText() {
487
498
  return [
488
- 'atris team - active members and the rest of the roster',
499
+ 'atris team - who does each job, with its tool and model, then active members and the rest',
489
500
  'atris team presence - show who is awake and what they are doing',
490
501
  'atris team prune - flag members with no recent activity; deletes nothing',
491
502
  '',
@@ -535,9 +546,15 @@ function teamCommand(args = [], deps = {}) {
535
546
  (deps.write || process.stdout.write.bind(process.stdout))(`${outPath}\n`);
536
547
  return 0;
537
548
  }
549
+ // Which job, tool, and model each member runs, from the same resolver
550
+ // as `atris engine roster`. A roster that cannot be read never hides the
551
+ // team; it costs one plain line.
552
+ const lineup = deps.lineup !== undefined
553
+ ? deps.lineup
554
+ : readLineupSafe(deps.root || repoRoot(deps.cwd || process.cwd()), deps.lineupNow || new Date());
538
555
  const output = json
539
- ? JSON.stringify(roster, null, 2)
540
- : renderTeamRoster(roster, deps);
556
+ ? JSON.stringify(roster.map((entry) => ({ ...entry, lineup: memberLineup(lineup, entry.name) })), null, 2)
557
+ : renderTeamWithLineup(roster, lineup, deps);
541
558
  (deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
542
559
  return 0;
543
560
  }
package/lib/host.js CHANGED
@@ -4,15 +4,17 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
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 });
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'];
9
- const INTRO_KEYS = ['a', 'b', 'attempt_id', 'reason', 'activity', 'version', 'a_said', 'b_said', 'state', 'created_at', 'expires_at', 'closed_reason'];
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: 2, 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', 'started_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', 'scheduled_at'];
10
+ const NEW_INTRO_KEYS = ['introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for', 'scheduled_at'];
10
11
  const CARD_KEYS = ['into_lately', 'going_for', 'great_at', 'wants_to_meet', 'worth_celebrating'];
11
12
  const CARD_TITLES = ['Into lately', 'Going for', 'Great at', 'Wants to meet', 'Worth celebrating'];
12
13
 
13
14
  function fail(message) { throw new Error(message); }
14
15
  function required(value, label) { if (typeof value !== 'string' || !value.trim()) fail(`${label} is required`); return value.trim(); }
15
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; }
16
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; }
17
19
  function fileId(id) { return encodeURIComponent(safeId(id)); }
18
20
  function days(iso, count) { return new Date(Date.parse(iso) + count * 86400000).toISOString(); }
@@ -42,7 +44,7 @@ function rows(file) {
42
44
  });
43
45
  }
44
46
  function putRows(file, entries) { atomic(file, entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '')); }
45
- function record(file, keys) {
47
+ function record(file, keys, optional = []) {
46
48
  const raw = readText(file);
47
49
  if (raw === null) fail(`missing record: ${file}`);
48
50
  const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
@@ -53,7 +55,8 @@ function record(file, keys) {
53
55
  if (!part || !keys.includes(part[1]) || Object.hasOwn(data, part[1])) fail(`malformed record: ${file}`);
54
56
  try { data[part[1]] = JSON.parse(part[2]); } catch { fail(`malformed record: ${file}`); }
55
57
  }
56
- if (keys.some((key) => !Object.hasOwn(data, key))) fail(`malformed record: ${file}`);
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;
57
60
  return { data, body: match[2] };
58
61
  }
59
62
  function putRecord(file, keys, data, body = '') {
@@ -81,25 +84,29 @@ function config(root) {
81
84
  const file = path.join(privateDir(root), 'config.json');
82
85
  if (!fs.existsSync(file)) putJson(file, DEFAULT_CONFIG);
83
86
  const value = json(file);
84
- if (!value || Object.keys(DEFAULT_CONFIG).some((key) => !Number.isInteger(value[key]) || value[key] < 1)) fail(`malformed config: ${file}`);
85
- return value;
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 };
86
92
  }
87
93
  function people(root) {
88
94
  const dir = path.join(privateDir(root), 'people');
89
95
  if (!fs.existsSync(dir)) return [];
90
- return fs.readdirSync(dir).filter((name) => name.endsWith('.md')).map((name) => record(path.join(dir, name), PERSON_KEYS)).sort((a, b) => a.data.name.localeCompare(b.data.name));
96
+ return fs.readdirSync(dir).filter((name) => name.endsWith('.md')).map((name) => record(path.join(dir, name), PERSON_KEYS, ['rest_until', 'started_at'])).sort((a, b) => a.data.name.localeCompare(b.data.name));
91
97
  }
92
- function person(root, id) { return record(personPath(root, id), PERSON_KEYS); }
98
+ function person(root, id) { return record(personPath(root, id), PERSON_KEYS, ['rest_until', 'started_at']); }
93
99
  function savePerson(root, entry) { entry.data.revision += 1; putRecord(personPath(root, entry.data.id), PERSON_KEYS, entry.data, entry.body); }
94
100
  function intros(root) {
95
101
  const dir = introDir(root);
96
102
  if (!fs.existsSync(dir)) return [];
97
- return fs.readdirSync(dir).filter((name) => name.endsWith('.md')).map((name) => ({ file: path.join(dir, name), ...record(path.join(dir, name), INTRO_KEYS) }));
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) }));
98
104
  }
99
- function queue(root, to, kind, text, at, attemptId = null) {
105
+ function queue(root, to, kind, text, at, attemptId = null, questionId = null) {
100
106
  const recipient = person(root, to).data;
101
107
  const message = { id: crypto.randomUUID(), to, door: recipient.door, kind, text, created_at: at, state: 'queued' };
102
108
  if (attemptId) message.attempt_id = attemptId;
109
+ if (questionId) message.question_id = questionId;
103
110
  putJson(path.join(outboxDir(root), `${message.id}.json`), message);
104
111
  return message;
105
112
  }
@@ -132,12 +139,54 @@ function expireQuestions(root, at, settings) {
132
139
  dropQueued(root, (message) => message.to === p.id && message.kind === 'question');
133
140
  if (p.unanswered_count >= settings.pause_after_unanswered) {
134
141
  p.status = 'paused';
142
+ p.rest_until = days(at, settings.rest_days);
135
143
  closeFor(root, p.id, 'pause');
136
144
  dropQueued(root, (message) => message.to === p.id);
137
145
  }
138
146
  savePerson(root, entry);
139
147
  }
140
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, skipAttempt = null) {
170
+ for (const entry of intros(root)) {
171
+ const intro = entry.data;
172
+ if (intro.state !== 'introduced' || intro.attempt_id === skipAttempt || !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
+ }
141
190
  function cardText(name, team, values) {
142
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`;
143
192
  }
@@ -153,9 +202,17 @@ function cardValues(raw) {
153
202
  return values;
154
203
  }
155
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()); }
156
207
  function pair(a, b) { return [safeId(a), safeId(b)].sort(); }
208
+ function introLimit(person, at, settings) {
209
+ const started = Date.parse(person.started_at || person.joined_at), current = Date.parse(at);
210
+ return started <= current && started >= current - 30 * 86400000 ? settings.newcomer_intros_first_30d : settings.intros_per_person_per_30d;
211
+ }
157
212
 
158
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');
159
216
  if (command === 'view') {
160
217
  if (arg.as && arg.team) fail('choose --as or --team');
161
218
  if (arg.as) {
@@ -170,13 +227,50 @@ function hostAction(root, command, arg = {}) {
170
227
  const at = nowIso(arg.now);
171
228
  expireIntros(root, at);
172
229
  expireQuestions(root, at, settings);
230
+ resumeResting(root, at);
231
+ queueNudges(root, at);
232
+ queueFollowups(root, at, command === 'scheduled' && arg.at != null ? arg.id : null);
173
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
+ if (arg.at != null) {
245
+ if (typeof arg.at !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(arg.at)) fail('at must be an ISO time');
246
+ entry.data.scheduled_at = nowIso(arg.at);
247
+ if (!entry.data.followup_sent) entry.data.followup_at = new Date(Math.max(Date.parse(at), Date.parse(entry.data.scheduled_at) + 86400000)).toISOString();
248
+ }
249
+ entry.data.scheduled_for = when;
250
+ entry.data.schedule_requested = false;
251
+ putRecord(entry.file, INTRO_KEYS, entry.data, entry.body);
252
+ for (const [id, other] of [[entry.data.a, entry.data.b], [entry.data.b, entry.data.a]]) {
253
+ queue(root, id, 'booked', `Booked: ${entry.data.activity} with ${person(root, other).data.name}, ${when}. Have fun.`, at, attempt);
254
+ }
255
+ return { attempt_id: attempt, scheduled_for: when };
256
+ }
257
+ if (command === 'people') {
258
+ const history = intros(root);
259
+ const recent = history.filter((entry) => Date.parse(entry.data.created_at) > Date.parse(at) - 30 * 86400000);
260
+ return people(root).filter((entry) => entry.data.status === 'active').map((entry) => ({
261
+ id: entry.data.id, name: entry.data.name, team: entry.data.team, manager_id: entry.data.manager_id, status: entry.data.status,
262
+ 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),
263
+ ...cardValues(published(root, entry.data.id)),
264
+ }));
265
+ }
174
266
  if (command === 'join') {
175
267
  const id = safeId(arg.id);
176
268
  if (rows(path.join(privateDir(root), 'forgotten.jsonl')).some((row) => row.id === id)) fail('this id was forgotten and cannot rejoin');
177
269
  if (fs.existsSync(personPath(root, id))) fail('person already joined');
178
270
  const name = singleLine(arg.name, 'name');
179
- 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: [] };
271
+ if (arg.started != null && (typeof arg.started !== 'string' || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2}))?$/.test(arg.started))) fail('started must be an ISO date or time');
272
+ const started = arg.started == null ? at : nowIso(arg.started);
273
+ 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, started_at: started, 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 };
180
274
  if (data.manager_id) safeId(data.manager_id);
181
275
  putRecord(personPath(root, id), PERSON_KEYS, data, '');
182
276
  if (!fs.existsSync(cardPath(root, id))) atomic(cardPath(root, id), cardText(name, data.team, {}));
@@ -186,6 +280,7 @@ function hostAction(root, command, arg = {}) {
186
280
  if (['leave', 'pause', 'resume'].includes(command)) {
187
281
  const entry = person(root, arg.id);
188
282
  entry.data.status = command === 'resume' ? 'active' : command === 'leave' ? 'left' : 'paused';
283
+ entry.data.rest_until = null;
189
284
  if (command !== 'resume') { closeFor(root, arg.id, command); dropQueued(root, (message) => message.to === arg.id); }
190
285
  if (command === 'resume') { entry.data.next_question_at = at; entry.data.unanswered_count = 0; }
191
286
  savePerson(root, entry);
@@ -211,7 +306,8 @@ function hostAction(root, command, arg = {}) {
211
306
  return { id, forgotten: true };
212
307
  }
213
308
  if (command === 'due') {
214
- return people(root).filter((entry) => entry.data.status === 'active' && !entry.data.pending_question_id && entry.data.next_question_at <= at).map((entry) => ({ id: entry.data.id, name: entry.data.name, revision: entry.data.revision, card: published(root, entry.data.id) }));
309
+ const open = intros(root);
310
+ 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) }));
215
311
  }
216
312
  if (command === 'ask') {
217
313
  const entry = person(root, arg.id);
@@ -223,7 +319,7 @@ function hostAction(root, command, arg = {}) {
223
319
  entry.data.pending_expires_at = days(at, settings.question_expiry_days);
224
320
  entry.data.next_question_at = days(at, settings.cadence_days);
225
321
  savePerson(root, entry);
226
- const message = queue(root, arg.id, 'question', question, at);
322
+ const message = queue(root, arg.id, 'question', question, at, null, entry.data.pending_question_id);
227
323
  return { id: arg.id, question_id: entry.data.pending_question_id, message_id: message.id, revision: entry.data.revision };
228
324
  }
229
325
  if (command === 'receive') {
@@ -236,31 +332,60 @@ function hostAction(root, command, arg = {}) {
236
332
  if (entry.data.status !== 'active') fail('person is not active');
237
333
  const answer = required(arg.text, 'text');
238
334
  entry.data.processed_events = [...entry.data.processed_events, eventId].slice(-200);
239
- const open = intros(root).find((intro) => intro.data.state === 'pending' && [intro.data.a, intro.data.b].includes(entry.data.id) && intro.data[intro.data.a === entry.data.id ? 'a_said' : 'b_said'] === null);
240
- const normalized = answer.toLowerCase().trim();
241
- const yes = ['yes', 'y', 'yeah', 'sure'].includes(normalized);
242
- const no = ['no', 'n', 'nah', 'pass'].includes(normalized);
335
+ const history = intros(root);
336
+ const open = history.find((intro) => awaiting(intro, entry.data.id));
337
+ const decision = arg.decision || choice(answer);
338
+ const ref = arg.replyToRef == null ? null : providerRef(arg.replyToRef);
339
+ const reply = arg.replyTo ? messages(root).find((message) => message.id === arg.replyTo)
340
+ : ref ? messages(root).find((message) => message.ref === ref && message.to === entry.data.id) : null;
341
+ const routed = reply && reply.to === entry.data.id ? reply : null;
342
+ const replyTo = arg.replyTo || routed?.id;
343
+ const intro = routed && ['intro_ask', 'clarify'].includes(routed.kind)
344
+ ? history.find((candidate) => candidate.data.attempt_id === routed.attempt_id && awaiting(candidate, entry.data.id)) : !replyTo && decision ? open : null;
345
+ 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)
346
+ : !replyTo && !intro && !!entry.data.pending_question_id;
347
+ 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;
348
+ 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));
349
+ const openNudges = nudges.filter((candidate) => !candidate.data.followup_sent);
350
+ const nudge = routed?.kind === 'nudge' ? nudges.find((candidate) => candidate.data.attempt_id === routed.attempt_id) : !replyTo && decision && openNudges.length === 1 ? openNudges[0] : null;
243
351
  let kind = 'note';
244
- if (open && (yes || no)) {
245
- open.data[open.data.a === entry.data.id ? 'a_said' : 'b_said'] = yes ? 'yes' : 'no';
246
- if (no) { closeIntro(root, open, 'declined'); kind = 'intro_closed'; }
247
- else if (open.data.a_said === 'yes' && open.data.b_said === 'yes') {
248
- open.data.state = 'ready'; putRecord(open.file, INTRO_KEYS, open.data, open.body);
249
- queue(root, open.data.a, 'intro', open.body, at, open.data.attempt_id);
250
- queue(root, open.data.b, 'intro', open.body, at, open.data.attempt_id);
251
- open.data.state = 'introduced'; putRecord(open.file, INTRO_KEYS, open.data, open.body);
352
+ if (nudge && decision) {
353
+ if (decision === 'yes') { nudge.data.schedule_requested = true; putRecord(nudge.file, INTRO_KEYS, nudge.data, nudge.body); }
354
+ kind = 'nudge';
355
+ } else if (intro && decision) {
356
+ intro.data[intro.data.a === entry.data.id ? 'a_said' : 'b_said'] = decision;
357
+ if (decision === 'no') { closeIntro(root, intro, 'declined'); kind = 'intro_closed'; }
358
+ else if (intro.data.a_said === 'yes' && intro.data.b_said === 'yes') {
359
+ intro.data.state = 'ready'; putRecord(intro.file, INTRO_KEYS, intro.data, intro.body);
360
+ queue(root, intro.data.a, 'intro', intro.body, at, intro.data.attempt_id);
361
+ queue(root, intro.data.b, 'intro', intro.body, at, intro.data.attempt_id);
362
+ intro.data.state = 'introduced';
363
+ intro.data.introduced_at = at;
364
+ intro.data.nudge_at = days(at, settings.nudge_days);
365
+ intro.data.followup_at = days(at, settings.followup_days);
366
+ putRecord(intro.file, INTRO_KEYS, intro.data, intro.body);
252
367
  const linkFile = path.join(privateDir(root), 'links.jsonl');
253
- putRows(linkFile, [...rows(linkFile), { a: open.data.a, b: open.data.b, source: 'intro', evidence: 'introduced by the Host', at }]);
368
+ putRows(linkFile, [...rows(linkFile), { a: intro.data.a, b: intro.data.b, source: 'intro', evidence: 'introduced by the Host', at }]);
254
369
  kind = 'introduced';
255
- } else { putRecord(open.file, INTRO_KEYS, open.data, open.body); kind = 'intro_yes'; }
256
- } else if (entry.data.pending_question_id && !yes && !no) {
370
+ } else { putRecord(intro.file, INTRO_KEYS, intro.data, intro.body); kind = 'intro_yes'; }
371
+ } else if (question) {
257
372
  entry.body += `## ${at}\n\nQ: ${entry.data.pending_question}\n\nA: ${answer}\n\n`;
258
373
  entry.data.pending_question_id = null; entry.data.pending_question = null; entry.data.pending_expires_at = null; entry.data.unanswered_count = 0;
259
374
  kind = 'answer';
260
- } else {
261
- entry.body += `## ${at}\n\nNote: ${answer}\n\n`;
262
- if (open) { queue(root, entry.data.id, 'clarify', 'Would you like this introduction? Please reply yes or no.', at); kind = 'clarify'; }
375
+ } else if (followup && decision) {
376
+ followup.data[followup.data.a === entry.data.id ? 'a_met' : 'b_met'] = decision;
377
+ putRecord(followup.file, INTRO_KEYS, followup.data, followup.body);
378
+ if (followup.data.a_met === 'yes' && followup.data.b_met === 'yes') {
379
+ const linkFile = path.join(privateDir(root), 'links.jsonl');
380
+ putRows(linkFile, [...rows(linkFile), { a: followup.data.a, b: followup.data.b, source: 'met', evidence: 'both people confirmed they met', at }]);
381
+ }
382
+ kind = 'followup';
383
+ } else if (open && !question && !decision && (!replyTo || ['intro_ask', 'clarify'].includes(routed?.kind))) {
384
+ const other = open.data.a === entry.data.id ? open.data.b : open.data.a;
385
+ 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);
386
+ kind = 'clarify';
263
387
  }
388
+ if (kind !== 'answer') entry.body += `## ${at}\n\nNote: ${answer}\n\n`;
264
389
  savePerson(root, entry);
265
390
  return { id: entry.data.id, kind, revision: entry.data.revision };
266
391
  }
@@ -278,7 +403,7 @@ function hostAction(root, command, arg = {}) {
278
403
  const [a, b] = pair(arg.a, arg.b);
279
404
  if (a === b) fail('a person cannot link to themselves');
280
405
  person(root, a); person(root, b);
281
- if (!['channel', 'answer', 'card', 'intro'].includes(arg.source)) fail('invalid link source');
406
+ if (!['channel', 'answer', 'card', 'intro', 'met'].includes(arg.source)) fail('invalid link source');
282
407
  const evidence = required(arg.evidence, 'evidence');
283
408
  const file = path.join(privateDir(root), 'links.jsonl');
284
409
  const links = rows(file);
@@ -296,11 +421,12 @@ function hostAction(root, command, arg = {}) {
296
421
  const history = intros(root);
297
422
  if (history.some((entry) => entry.data.a === a && entry.data.b === b && entry.data.closed_reason === 'declined')) fail('this pair declined before');
298
423
  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');
424
+ 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');
299
425
  const recent = history.filter((entry) => Date.parse(entry.data.created_at) > Date.parse(at) - 30 * 86400000);
300
- for (const id of [a, b]) if (recent.filter((entry) => [entry.data.a, entry.data.b].includes(id)).length >= settings.intros_per_person_per_30d) fail('introduction cadence reached');
301
- const reason = required(arg.reason, 'reason'), activity = required(arg.activity, 'activity'), wording = required(arg.text, 'text');
426
+ 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');
427
+ const reason = required(arg.reason, 'reason'), activity = phrase(required(arg.activity, 'activity')), wording = required(arg.text, 'text');
302
428
  const attempt = crypto.randomUUID();
303
- 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 };
429
+ 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, scheduled_at: null };
304
430
  const file = path.join(introDir(root), `${fileId(a)}--${fileId(b)}--${attempt}.md`);
305
431
  putRecord(file, INTRO_KEYS, data, wording);
306
432
  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.`;
@@ -315,17 +441,26 @@ function hostAction(root, command, arg = {}) {
315
441
  const file = path.join(outboxDir(root), `${id}.json`);
316
442
  const message = json(file);
317
443
  if (!message) fail('unknown message');
444
+ if (arg.ref != null) message.ref = providerRef(arg.ref);
318
445
  message.state = 'sent'; putJson(file, message);
319
446
  return { id, state: 'sent' };
320
447
  }
321
448
  if (command === 'room') {
322
449
  const roster = people(root);
323
450
  const links = rows(path.join(privateDir(root), 'links.jsonl'));
451
+ const history = intros(root);
324
452
  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);
325
- const celebrate = roster.filter((entry) => entry.data.status !== 'left').flatMap((entry) => { const value = cardValues(published(root, entry.data.id)).worth_celebrating; return value ? [`${entry.data.name}: ${value}`] : []; });
326
- const flight = intros(root).filter((entry) => ['pending', 'ready'].includes(entry.data.state)).length;
327
- 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);
328
- 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## Suggested welcomes\n\n${recent.join('\n') || 'None'}\n`;
453
+ 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}`] : []; });
454
+ const flight = history.filter((entry) => ['pending', 'ready'].includes(entry.data.state)).length;
455
+ const happened = history.filter((entry) => entry.data.a_met === 'yes' && entry.data.b_met === 'yes').length;
456
+ const current = Date.parse(at);
457
+ const startedAt = (entry) => Date.parse(entry.data.started_at || entry.data.joined_at);
458
+ const cohort = roster.filter((entry) => entry.data.status === 'active' && startedAt(entry) >= current - 120 * 86400000 && startedAt(entry) <= current - 30 * 86400000);
459
+ const connected = cohort.filter((entry) => new Set(links.filter((link) => link.source === 'met' && Date.parse(link.at) <= startedAt(entry) + 30 * 86400000 && [link.a, link.b].includes(entry.data.id)).map((link) => link.a === entry.data.id ? link.b : link.a)).size >= 2).length;
460
+ const firstMonth = roster.filter((entry) => entry.data.status === 'active' && startedAt(entry) > current - 30 * 86400000 && startedAt(entry) <= current).length;
461
+ const coverage = `${cohort.length < 10 ? 'Not enough people have finished their first month yet to report this (need 10).' : `${connected} of ${cohort.length} people who joined in the last few months found at least two people in their first 30 days.`}\nIn their first month now: ${firstMonth}`;
462
+ const recent = roster.filter((entry) => entry.data.status === 'active' && startedAt(entry) >= current - 7 * 86400000).map((entry) => entry.data.name);
463
+ 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`;
329
464
  atomic(path.join(privateDir(root), 'room.md'), content);
330
465
  return { text: content };
331
466
  }
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']),
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ // Who does each job, worded for `atris team` and the session boot. The rows
4
+ // come from the same resolver `atris engine roster` uses (jobRosterView and
5
+ // rosterReport in commands/engine.js); this file only picks and words them.
6
+
7
+ const { engineRunsView, modelLabel } = require('./roster-models');
8
+ const { rosterToolLabel } = require('./engine-registry');
9
+
10
+ const BUILT_IN_JOBS = Object.freeze(['build', 'review', 'search']);
11
+ const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
12
+
13
+ // Built-in jobs in the order people say them, then the owner's own jobs.
14
+ function orderJobs(rows) {
15
+ const list = Array.isArray(rows) ? rows : [];
16
+ const builtIn = BUILT_IN_JOBS.map((job) => list.find((row) => row.job === job && !row.key)).filter(Boolean);
17
+ return [...builtIn, ...list.filter((row) => !builtIn.includes(row))];
18
+ }
19
+
20
+ // "opus 5.5" for a claude model, "codex gpt-6-astra" for anything else, and
21
+ // just the tool when it runs its own default.
22
+ function shortRuns(view) {
23
+ if (!view || !view.engine) return '';
24
+ const tool = rosterToolLabel(view.engine);
25
+ if (!view.model) return tool;
26
+ const label = modelLabel(view.model);
27
+ // "atris:fast" on atris fast says the tool twice; "grok 4.7 fast" on grok
28
+ // already names it.
29
+ const plain = (text) => String(text).toLowerCase().replace(/[^a-z0-9.]+/g, ' ').trim();
30
+ if (plain(label) === plain(tool)) return tool;
31
+ if (CLAUDE_FAMILY.includes(view.engine) || plain(label).startsWith(`${plain(tool)} `)) return label;
32
+ return `${tool} ${label}`;
33
+ }
34
+
35
+ // The full lineup: every job and every member, read once. Throws when the
36
+ // roster cannot be read; callers decide how to say so.
37
+ function readLineup(root = process.cwd(), now = new Date()) {
38
+ const { rosterReport } = require('../commands/engine');
39
+ const report = rosterReport(root, now);
40
+ return { jobs: orderJobs(report.jobs), team: report.team || [] };
41
+ }
42
+
43
+ // The same read, never throwing: { ok: false, error } when it fails.
44
+ function readLineupSafe(root = process.cwd(), now = new Date()) {
45
+ try {
46
+ return { ok: true, ...readLineup(root, now) };
47
+ } catch (error) {
48
+ return { ok: false, error: String((error && error.message) || error || 'unknown error'), jobs: [], team: [] };
49
+ }
50
+ }
51
+
52
+ // One boot line: the tool and model that runs each built-in job right now,
53
+ // custom jobs only when all of them still fit. '' when nothing resolves.
54
+ function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs = null } = {}) {
55
+ let rows = jobs;
56
+ if (!rows) {
57
+ const { jobRosterView } = require('../commands/engine');
58
+ rows = jobRosterView(root, now);
59
+ }
60
+ const ordered = orderJobs(rows);
61
+ const part = (row) => {
62
+ const text = shortRuns(row.now_runs);
63
+ return text ? `${row.job} ${text}` : '';
64
+ };
65
+ const builtIn = ordered.filter((row) => BUILT_IN_JOBS.includes(row.job) && !row.key).map(part).filter(Boolean);
66
+ const custom = ordered.filter((row) => !BUILT_IN_JOBS.includes(row.job) || row.key).map(part).filter(Boolean);
67
+ if (!builtIn.length && !custom.length) return '';
68
+ const all = [...builtIn, ...custom].join(' · ');
69
+ if (all.length <= width || !builtIn.length) return all;
70
+ return builtIn.join(' · ');
71
+ }
72
+
73
+ function runsText(view) {
74
+ return view && view.text ? view.text : 'no ready tool';
75
+ }
76
+
77
+ // "who does each job" lines: the tool and model running each job now, its
78
+ // backup, and a short note when the router or a backup is standing in.
79
+ function renderJobLines(jobs, { width = 80 } = {}) {
80
+ const rows = orderJobs(jobs);
81
+ if (!rows.length) return [];
82
+ const labelWidth = Math.max(...rows.map((row) => row.job.length));
83
+ return rows.map((row) => {
84
+ const head = ` ${row.job.padEnd(labelWidth)} ${runsText(row.now_runs)}`;
85
+ let note = '';
86
+ if (!row.pick) note = row.like ? `, same as ${row.like}` : ', picked by the router';
87
+ else if (row.lead === 'first' && row.backup_runs) note = `, backup ${row.backup_runs.text}`;
88
+ else if (row.lead !== 'first') note = row.status === 'expired' ? ', first pick expired' : ', first pick not ready';
89
+ const line = `${head}${note}`;
90
+ return line.length <= width || row.lead !== 'first' ? line : head;
91
+ });
92
+ }
93
+
94
+ // Names under each job, wrapped to the width. A member running something
95
+ // other than its job's tool and model says what it runs.
96
+ function renderMemberLines(team, jobs, { width = 80 } = {}) {
97
+ const members = Array.isArray(team) ? team : [];
98
+ if (!members.length) return [];
99
+ const rows = orderJobs(jobs);
100
+ const order = [...rows.map((row) => row.job)];
101
+ for (const member of members) if (member.job && !order.includes(member.job)) order.push(member.job);
102
+ const groups = order
103
+ .map((job) => ({ job, row: rows.find((row) => row.job === job) || null, names: members.filter((member) => member.job === job) }))
104
+ .filter((group) => group.names.length);
105
+ const labelWidth = Math.max(...groups.map((group) => group.job.length));
106
+ const indent = ' '.repeat(labelWidth + 4);
107
+ const lines = [];
108
+ for (const group of groups) {
109
+ const jobText = group.row && group.row.now_runs ? group.row.now_runs.text : '';
110
+ const names = group.names.map((member) => {
111
+ const text = member.engine ? engineRunsView(member.engine, { model: member.model || '', effort: member.effort || '' }).text : 'no ready tool';
112
+ return text === jobText ? member.member : `${member.member} on ${text}`;
113
+ });
114
+ let line = ` ${group.job.padEnd(labelWidth)} `;
115
+ let fresh = true;
116
+ for (const name of names) {
117
+ const piece = fresh ? name : `, ${name}`;
118
+ if (!fresh && line.length + piece.length + 1 > width) {
119
+ lines.push(`${line},`);
120
+ line = `${indent}${name}`;
121
+ } else {
122
+ line += piece;
123
+ }
124
+ fresh = false;
125
+ }
126
+ lines.push(line);
127
+ }
128
+ return lines;
129
+ }
130
+
131
+ // The block `atris team` prints before today's active and rest lists.
132
+ function renderLineup(lineup, { width = 80 } = {}) {
133
+ if (!lineup || !lineup.ok) return '';
134
+ const jobLines = renderJobLines(lineup.jobs, { width });
135
+ const memberLines = renderMemberLines(lineup.team, lineup.jobs, { width });
136
+ const blocks = [];
137
+ if (jobLines.length) blocks.push(['who does each job:', ...jobLines].join('\n'));
138
+ if (memberLines.length) blocks.push(['who is on each job:', ...memberLines].join('\n'));
139
+ return blocks.join('\n\n');
140
+ }
141
+
142
+ // Per member, for json: the job, tool, model, and whether a roster line or
143
+ // the automatic match decided it. null for a name the lineup does not know.
144
+ function memberLineup(lineup, name) {
145
+ if (!lineup || !lineup.ok) return null;
146
+ const key = String(name || '').trim().toLowerCase();
147
+ const row = (lineup.team || []).find((member) => String(member.member || '').toLowerCase() === key);
148
+ if (!row) return null;
149
+ return {
150
+ job: row.job || null,
151
+ engine: row.engine || null,
152
+ model: row.model || null,
153
+ effort: row.effort || null,
154
+ source: row.source === 'file' ? 'roster' : 'automatic',
155
+ file: row.file || null,
156
+ };
157
+ }
158
+
159
+ const LINEUP_UNREADABLE = 'could not read who does each job, so tools and models are not shown. try: atris engine roster';
160
+
161
+ module.exports = {
162
+ LINEUP_UNREADABLE,
163
+ bootTeamLine,
164
+ memberLineup,
165
+ readLineupSafe,
166
+ renderLineup,
167
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.59.0",
3
+ "version": "3.61.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -1,13 +1,16 @@
1
1
  # Host operating loop
2
2
 
3
3
  Run in a workspace with `atris/`. Use `atris host <subcommand> --json` to read machine output. Treat all private paths as host-only. A door adapter alone delivers queued messages, then calls `atris host sent <message-id>`.
4
+ Doors pass `--ref <provider-ref>` when marking a message sent and `--reply-to-ref <provider-ref>` when handing back a reply.
5
+ When importing an existing team, pass `--started <ISO date>` to `atris host join` for each person's real company or club start date. Long-time staff will then stay out of the new-person welcome counts.
4
6
 
5
7
  1. Run `atris host due`. For each person, read their private record and published card. Pick one playful question that fills a real gap in the card. Ask with `atris host ask <id> --question "..."`. Do not repeat a question or pressure someone who goes quiet.
6
- 2. Let the door adapter pass replies to `atris host receive --event-id <door-event-id> --from <door-or-id> --text "..."`. Drain them before deciding what to do next. Update a published card with `atris host card <id> --patch <file.json> --expected-revision <revision>`. Keep answers and operational fields out of the card.
8
+ 2. Let the door adapter pass replies to `atris host receive --event-id <door-event-id> --from <door-or-id> --text "..." --reply-to <message-id>` when the door provides the original outbox message id. When a reply is not a plain yes or no, read it and pass `--decision yes` or `--decision no` with what the person meant. If it is truly unclear, pass no decision. Drain replies before deciding what to do next. Update a published card with `atris host card <id> --patch <file.json> --expected-revision <revision>`. Keep answers and operational fields out of the card.
7
9
  3. Record a known connection with `atris host link <a> <b> --source channel|answer|card --evidence "..."`. Use `card` only for a tentative clue. The code blocks introductions when another source confirms a link.
8
10
  Record a link only when two people already know each other. "Wants to meet" belongs on the card, not in links.
9
- 4. Propose at most one introduction per person in 30 days. Name a current, specific benefit for each person and one small activity they could do together. Shared interests alone are not enough. Check existing links and exclude manager and report pairs. Draft the exact text, then call `atris host propose <a> <b> --reason "..." --activity "..." --text "..."`. Two private yeses are required. A no is never disclosed.
11
+ 4. Read `atris host people --json` before proposing. Only propose people with `can_be_introduced` true. New people get a warmer welcome with up to 3 introductions in their first month; prioritize them when proposing. Everyone else gets at most two introductions per 30 days by default, unless the room has its own limit. Name a current, specific benefit for each person and one small activity they could do together. Shared interests alone are not enough. Check existing links and exclude manager and report pairs. Draft the exact text, then call `atris host propose <a> <b> --reason "..." --activity "..." --text "..."`. Two private yeses are required. A no is never disclosed.
10
12
  An introduction reason may use only what is already on both published cards, never private answers.
11
- 5. Once a week run `atris host room`. Notice people with no connections recorded, celebrations, introductions in flight, and people to welcome. Use this as a gentle prompt, never a ranking or performance report.
13
+ 5. Run `atris host schedule --json` after handling replies. For each requested introduction, use the calendar tools available in the workspace to find a time for both people, then call `atris host scheduled <attempt-id> --when "<time>" --at "<ISO time>"` whenever the calendar gives a real time. A nudge is private, and either person's yes is enough to request scheduling.
14
+ 6. Once a week run `atris host room`. Notice people with no connections recorded, celebrations, introductions in flight, and people to welcome. Use this as a gentle prompt, never a ranking or performance report.
12
15
 
13
16
  On `leave`, `pause`, or `forget`, respect the person's choice. Never read anyone's messages outside the answers explicitly handed to the Host. Never send directly from this skill.