atris 3.60.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/commands/host.js +1 -1
- package/lib/host.js +27 -17
- package/package.json +1 -1
- package/templates/members/host/skills/host/SKILL.md +3 -2
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', 'reply-to', 'reply-to-ref', 'ref', 'decision', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as', 'when']);
|
|
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; }
|
package/lib/host.js
CHANGED
|
@@ -4,10 +4,10 @@ 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', 'rest_until'];
|
|
9
|
-
const INTRO_KEYS = ['a', 'b', 'attempt_id', 'reason', 'activity', 'version', 'a_said', 'b_said', 'state', 'created_at', 'expires_at', 'closed_reason', 'introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for'];
|
|
10
|
-
const NEW_INTRO_KEYS = ['introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for'];
|
|
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'];
|
|
11
11
|
const CARD_KEYS = ['into_lately', 'going_for', 'great_at', 'wants_to_meet', 'worth_celebrating'];
|
|
12
12
|
const CARD_TITLES = ['Into lately', 'Going for', 'Great at', 'Wants to meet', 'Worth celebrating'];
|
|
13
13
|
|
|
@@ -93,9 +93,9 @@ function config(root) {
|
|
|
93
93
|
function people(root) {
|
|
94
94
|
const dir = path.join(privateDir(root), 'people');
|
|
95
95
|
if (!fs.existsSync(dir)) return [];
|
|
96
|
-
return fs.readdirSync(dir).filter((name) => name.endsWith('.md')).map((name) => record(path.join(dir, name), PERSON_KEYS, ['rest_until'])).sort((a, b) => a.data.name.localeCompare(b.data.name));
|
|
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));
|
|
97
97
|
}
|
|
98
|
-
function person(root, id) { return record(personPath(root, id), PERSON_KEYS, ['rest_until']); }
|
|
98
|
+
function person(root, id) { return record(personPath(root, id), PERSON_KEYS, ['rest_until', 'started_at']); }
|
|
99
99
|
function savePerson(root, entry) { entry.data.revision += 1; putRecord(personPath(root, entry.data.id), PERSON_KEYS, entry.data, entry.body); }
|
|
100
100
|
function intros(root) {
|
|
101
101
|
const dir = introDir(root);
|
|
@@ -166,10 +166,10 @@ function queueNudges(root, at) {
|
|
|
166
166
|
putRecord(entry.file, INTRO_KEYS, intro, entry.body);
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
-
function queueFollowups(root, at) {
|
|
169
|
+
function queueFollowups(root, at, skipAttempt = null) {
|
|
170
170
|
for (const entry of intros(root)) {
|
|
171
171
|
const intro = entry.data;
|
|
172
|
-
if (intro.state !== 'introduced' || !intro.followup_at || intro.followup_sent || intro.followup_at > at) continue;
|
|
172
|
+
if (intro.state !== 'introduced' || intro.attempt_id === skipAttempt || !intro.followup_at || intro.followup_sent || intro.followup_at > at) continue;
|
|
173
173
|
for (const [id, other] of [[intro.a, intro.b], [intro.b, intro.a]]) {
|
|
174
174
|
if (person(root, id).data.status !== 'active') continue;
|
|
175
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);
|
|
@@ -206,8 +206,8 @@ function published(root, id) { const raw = readText(cardPath(root, id)); if (raw
|
|
|
206
206
|
function phrase(text) { return text.replace(/[\s.!?]+$/, '').replace(/^(A|An|The|One)\b/, (word) => word.toLowerCase()); }
|
|
207
207
|
function pair(a, b) { return [safeId(a), safeId(b)].sort(); }
|
|
208
208
|
function introLimit(person, at, settings) {
|
|
209
|
-
const
|
|
210
|
-
return
|
|
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
211
|
}
|
|
212
212
|
|
|
213
213
|
function hostAction(root, command, arg = {}) {
|
|
@@ -229,7 +229,7 @@ function hostAction(root, command, arg = {}) {
|
|
|
229
229
|
expireQuestions(root, at, settings);
|
|
230
230
|
resumeResting(root, at);
|
|
231
231
|
queueNudges(root, at);
|
|
232
|
-
queueFollowups(root, at);
|
|
232
|
+
queueFollowups(root, at, command === 'scheduled' && arg.at != null ? arg.id : null);
|
|
233
233
|
if (command === 'outbox') return messages(root).filter((message) => message.state === 'queued');
|
|
234
234
|
if (command === 'schedule') return intros(root).filter((entry) => entry.data.state === 'introduced' && entry.data.schedule_requested && !entry.data.scheduled_for).map(({ data }) => ({
|
|
235
235
|
attempt_id: data.attempt_id, a: data.a, b: data.b,
|
|
@@ -241,6 +241,11 @@ function hostAction(root, command, arg = {}) {
|
|
|
241
241
|
if (!entry) fail('unknown introduced attempt');
|
|
242
242
|
if (entry.data.scheduled_for) fail('introduction already scheduled');
|
|
243
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
|
+
}
|
|
244
249
|
entry.data.scheduled_for = when;
|
|
245
250
|
entry.data.schedule_requested = false;
|
|
246
251
|
putRecord(entry.file, INTRO_KEYS, entry.data, entry.body);
|
|
@@ -263,7 +268,9 @@ function hostAction(root, command, arg = {}) {
|
|
|
263
268
|
if (rows(path.join(privateDir(root), 'forgotten.jsonl')).some((row) => row.id === id)) fail('this id was forgotten and cannot rejoin');
|
|
264
269
|
if (fs.existsSync(personPath(root, id))) fail('person already joined');
|
|
265
270
|
const name = singleLine(arg.name, 'name');
|
|
266
|
-
|
|
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 };
|
|
267
274
|
if (data.manager_id) safeId(data.manager_id);
|
|
268
275
|
putRecord(personPath(root, id), PERSON_KEYS, data, '');
|
|
269
276
|
if (!fs.existsSync(cardPath(root, id))) atomic(cardPath(root, id), cardText(name, data.team, {}));
|
|
@@ -419,7 +426,7 @@ function hostAction(root, command, arg = {}) {
|
|
|
419
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');
|
|
420
427
|
const reason = required(arg.reason, 'reason'), activity = phrase(required(arg.activity, 'activity')), wording = required(arg.text, 'text');
|
|
421
428
|
const attempt = crypto.randomUUID();
|
|
422
|
-
const data = { a, b, attempt_id: attempt, reason, activity, version: 1, a_said: null, b_said: null, state: 'pending', created_at: at, expires_at: days(at, settings.intro_expiry_days), closed_reason: null, introduced_at: null, followup_at: null, followup_sent: null, a_met: null, b_met: null, nudge_at: null, nudge_sent: null, schedule_requested: null, scheduled_for: null };
|
|
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 };
|
|
423
430
|
const file = path.join(introDir(root), `${fileId(a)}--${fileId(b)}--${attempt}.md`);
|
|
424
431
|
putRecord(file, INTRO_KEYS, data, wording);
|
|
425
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.`;
|
|
@@ -446,10 +453,13 @@ function hostAction(root, command, arg = {}) {
|
|
|
446
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}`] : []; });
|
|
447
454
|
const flight = history.filter((entry) => ['pending', 'ready'].includes(entry.data.state)).length;
|
|
448
455
|
const happened = history.filter((entry) => entry.data.a_met === 'yes' && entry.data.b_met === 'yes').length;
|
|
449
|
-
const
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
const
|
|
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);
|
|
453
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`;
|
|
454
464
|
atomic(path.join(privateDir(root), 'room.md'), content);
|
|
455
465
|
return { text: content };
|
package/package.json
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
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
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.
|
|
5
6
|
|
|
6
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.
|
|
7
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.
|
|
8
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.
|
|
9
10
|
Record a link only when two people already know each other. "Wants to meet" belongs on the card, not in links.
|
|
10
|
-
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
|
|
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.
|
|
11
12
|
An introduction reason may use only what is already on both published cards, never private answers.
|
|
12
|
-
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>"
|
|
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.
|
|
13
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.
|
|
14
15
|
|
|
15
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.
|