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 +10 -0
- package/commands/engine.js +2 -0
- package/commands/host.js +12 -3
- package/commands/team.js +20 -3
- package/lib/host.js +175 -40
- package/lib/intents.js +15 -0
- package/lib/team-lineup.js +167 -0
- package/package.json +1 -1
- package/templates/members/host/skills/host/SKILL.md +6 -3
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;
|
package/commands/engine.js
CHANGED
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
|
|
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
|
-
:
|
|
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:
|
|
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) =>
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
const
|
|
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 (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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:
|
|
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(
|
|
256
|
-
} else if (
|
|
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.
|
|
262
|
-
|
|
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
|
|
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
|
|
326
|
-
const flight =
|
|
327
|
-
const
|
|
328
|
-
const
|
|
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,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 "..."
|
|
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.
|
|
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.
|
|
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.
|