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 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: 1, newcomer_intros_first_30d: 3, followup_days: 14, nudge_days: 3, rest_days: 21 });
8
- const PERSON_KEYS = ['schema', 'id', 'name', 'team', 'manager_id', 'door', 'status', 'joined_at', 'told_host_sees_at', 'next_question_at', 'pending_question_id', 'pending_question', 'pending_expires_at', 'unanswered_count', 'revision', 'processed_events', 'rest_until'];
9
- const INTRO_KEYS = ['a', 'b', 'attempt_id', 'reason', 'activity', 'version', 'a_said', 'b_said', 'state', 'created_at', 'expires_at', 'closed_reason', 'introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for'];
10
- const NEW_INTRO_KEYS = ['introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for'];
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 joined = Date.parse(person.joined_at), current = Date.parse(at);
210
- return joined <= current && joined >= current - 30 * 86400000 ? settings.newcomer_intros_first_30d : settings.intros_per_person_per_30d;
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
- const data = { schema: 1, id, name, team: arg.team ? singleLine(arg.team, 'team') : '', manager_id: arg.manager || null, door: arg.door ? singleLine(arg.door, 'door') : id, status: 'active', joined_at: at, told_host_sees_at: at, next_question_at: at, pending_question_id: null, pending_question: null, pending_expires_at: null, unanswered_count: 0, revision: 1, processed_events: [], rest_until: null };
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 newcomers = roster.filter((entry) => entry.data.status === 'active' && Date.parse(entry.data.joined_at) >= Date.parse(at) - 30 * 86400000);
450
- const connectedNewcomers = newcomers.filter((entry) => new Set(links.filter((link) => link.source === 'met' && [link.a, link.b].includes(entry.data.id)).map((link) => link.a === entry.data.id ? link.b : link.a)).size >= 2).length;
451
- const coverage = newcomers.length < 10 ? 'Not enough new people yet to report this (need 10).' : `${connectedNewcomers} of ${newcomers.length} new people have at least two confirmed connections.`;
452
- const recent = roster.filter((entry) => entry.data.status === 'active' && Date.parse(entry.data.joined_at) >= Date.parse(at) - 7 * 86400000).map((entry) => entry.data.name);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.60.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": {
@@ -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 one introduction per 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.
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>"`. A nudge is private, and either person's yes is enough to request scheduling.
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.