atris 3.58.8 → 3.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +3 -2
  2. package/atris/skills/engines/SKILL.md +28 -1
  3. package/bin/atris.js +3 -0
  4. package/commands/autopilot.js +75 -5
  5. package/commands/clean.js +17 -0
  6. package/commands/doc-health.js +11 -6
  7. package/commands/engine.js +384 -4
  8. package/commands/host.js +53 -0
  9. package/commands/member.js +57 -6
  10. package/commands/mission.js +72 -8
  11. package/commands/one-lap.js +58 -3
  12. package/commands/run.js +4 -1
  13. package/commands/scout.js +1 -1
  14. package/commands/search.js +5 -3
  15. package/commands/unknowns.js +5 -0
  16. package/commands/verify.js +12 -4
  17. package/commands/visualize.js +20 -3
  18. package/commands/workflow.js +17 -6
  19. package/lib/dispatch-scout.js +31 -22
  20. package/lib/engine-registry.js +1482 -13
  21. package/lib/feedback-triage.js +2 -0
  22. package/lib/fleet.js +107 -11
  23. package/lib/host.js +336 -0
  24. package/lib/known-commands.js +1 -1
  25. package/lib/map-refs.js +44 -1
  26. package/lib/member-engine.js +253 -0
  27. package/lib/roster-markdown.js +206 -0
  28. package/lib/roster-models.js +179 -0
  29. package/lib/runner-command.js +94 -14
  30. package/lib/self-drive.js +9 -1
  31. package/lib/wish-audit.js +10 -6
  32. package/lib/wish-delegate.js +10 -0
  33. package/package.json +1 -1
  34. package/templates/members/host/MEMBER.md +23 -0
  35. package/templates/members/host/MISSION.md +11 -0
  36. package/templates/members/host/SOUL.md +7 -0
  37. package/templates/members/host/context/why.md +3 -0
  38. package/templates/members/host/goals.json +5 -0
  39. package/templates/members/host/goals.md +3 -0
  40. package/templates/members/host/skills/host/SKILL.md +13 -0
@@ -183,9 +183,11 @@ async function dispatchFeedbackTriage(brief, workspace, deps = {}) {
183
183
  : resolveEngine('executor', workspace, 'codex', { taskType: 'feedback_triage', lowStakes: true });
184
184
  const engine = selected && selected.engine && selected.engine.id;
185
185
  if (!engine) throw new Error('feedback triage found no ready engine');
186
+ const model = selected.engine.roster_model || '';
186
187
 
187
188
  const answers = await dispatch([{
188
189
  engine,
190
+ ...(model ? { model } : {}),
189
191
  prompt: brief.prompt,
190
192
  label: `feedback ${brief.feedback_id}`,
191
193
  }], {
package/lib/fleet.js CHANGED
@@ -37,6 +37,7 @@ const { sleepSync } = require('./sleep-sync');
37
37
  const { ownerIdentity } = require('../utils/owner-identity');
38
38
  const { matchTaste } = require('./taste-lessons');
39
39
  const { buildVerifiedScoutPack, appendVerifiedScoutPack } = require('./dispatch-scout');
40
+ const { mapNotesPath } = require('./map-refs');
40
41
  const {
41
42
  applySecretGrantEnvironment,
42
43
  buildGatewaySupervisorScript,
@@ -110,7 +111,9 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
110
111
  lines.push(`Check: ${check}`, '');
111
112
  lines.push(
112
113
  'Rules:',
113
- '- Read atris/MAP.md first to locate the code; never guess file locations.',
114
+ mapNotesPath(worktreePath || process.cwd())
115
+ ? '- Read atris/MAP.md first to locate the code, then atris/refs/MAP-NOTES.md for exact file:line refs; never guess file locations.'
116
+ : '- Read atris/MAP.md first to locate the code; never guess file locations.',
114
117
  '- Keep one concern per PR; split anything larger into separate PRs because git history guides future agents and small PRs are cheap to revert and bisect.',
115
118
  '- Run git status first. Stage ONLY files you changed. Never revert or touch files another agent modified.',
116
119
  '- Include or update a focused regression test; run it with npm run test:fast && node --test <focused files> before committing.',
@@ -205,21 +208,37 @@ function assertIsolatedWorktree(worktreePath, root = process.cwd()) {
205
208
  return { worktreePath: resolvedWorktree, primaryRoot };
206
209
  }
207
210
 
208
- function wrapCodexWithWatchdog(cmd, watchdogPath = CODEX_WATCHDOG_SOURCE, receiptPath = '') {
211
+ // A roster time cap, in whole seconds, or 0 when none is set.
212
+ function capSeconds(value) {
213
+ const seconds = Math.floor(Number(value));
214
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
215
+ }
216
+
217
+ // The pin a validator runs with: a model string (older callers) or
218
+ // { model, effort, max_seconds } from the roster.
219
+ function validatorPin(validatorModels, engine) {
220
+ const pin = validatorModels && validatorModels[engine];
221
+ if (!pin) return { model: '', effort: '', maxSeconds: 0 };
222
+ if (typeof pin === 'string') return { model: pin, effort: '', maxSeconds: 0 };
223
+ return { model: String(pin.model || ''), effort: String(pin.effort || ''), maxSeconds: capSeconds(pin.max_seconds) };
224
+ }
225
+
226
+ function wrapCodexWithWatchdog(cmd, watchdogPath = CODEX_WATCHDOG_SOURCE, receiptPath = '', maxSeconds = 0) {
209
227
  // Codex hangs forever at startup when stdin is an open pipe (three real
210
228
  // flights on 2026-08-11 sat 50-100 minutes at zero CPU). Force every fleet
211
229
  // codex spawn through the silent-start / runtime-cap watchdog with stdin
212
230
  // sealed, so orchestrator prompts cannot forget the convention.
213
231
  const receiptArg = receiptPath ? ` --receipt ${shellSingleQuote(receiptPath)}` : '';
214
- return `${shellSingleQuote(process.execPath)} ${shellSingleQuote(watchdogPath)} --startup-deadline 90 --max-runtime 3600${receiptArg} -- sh -c ${shellSingleQuote(cmd)} </dev/null`;
232
+ const maxRuntime = capSeconds(maxSeconds) || 3600;
233
+ return `${shellSingleQuote(process.execPath)} ${shellSingleQuote(watchdogPath)} --startup-deadline 90 --max-runtime ${maxRuntime}${receiptArg} -- sh -c ${shellSingleQuote(cmd)} </dev/null`;
215
234
  }
216
235
 
217
- function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = false, allowedTools = FLEET_ALLOWED_TOOLS, watchdogPath = CODEX_WATCHDOG_SOURCE, watchdogReceiptPath = '' } = {}) {
236
+ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = false, allowedTools = FLEET_ALLOWED_TOOLS, watchdogPath = CODEX_WATCHDOG_SOURCE, watchdogReceiptPath = '', model = '', effort = '', maxSeconds = 0 } = {}) {
218
237
  if (!RUNNER_PROFILE_DEFS[engineName]) throw new Error(`unknown engine "${engineName}"`);
219
238
  const prev = process.env.ATRIS_RUNNER_PROFILE;
220
239
  process.env.ATRIS_RUNNER_PROFILE = engineName;
221
240
  try {
222
- let cmd = buildRunnerCommand({ promptFile, allowedTools });
241
+ let cmd = buildRunnerCommand({ promptFile, allowedTools, ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
223
242
  // devin's default is read-only for writes; fleet builds ALWAYS run in an
224
243
  // isolated worktree, so the conductor grants write permission here and
225
244
  // only here (the profile itself stays safe for non-worktree ticks).
@@ -243,7 +262,7 @@ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = fal
243
262
  if (yolo && engineName === 'codex') cmd = cmd.replace(/\bexec\b/, `exec ${YOLO_ENGINE_FLAGS.codex}`);
244
263
  if (yolo && (engineName === 'claude' || engineName === 'fable')) cmd = `${cmd} ${YOLO_ENGINE_FLAGS.claude}`;
245
264
  if (yolo && engineName === 'commandcode') cmd = `${cmd} --yolo`;
246
- if (engineName === 'codex') cmd = wrapCodexWithWatchdog(cmd, watchdogPath, watchdogReceiptPath);
265
+ if (engineName === 'codex') cmd = wrapCodexWithWatchdog(cmd, watchdogPath, watchdogReceiptPath, maxSeconds);
247
266
  return cmd;
248
267
  } finally {
249
268
  if (prev === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
@@ -303,6 +322,10 @@ function terminateSameSandboxProfile(executable, args, options) {
303
322
  });
304
323
  }
305
324
 
325
+ // Every parent signal guard a dispatch installs, so one dispatch can tell
326
+ // another dispatch's guard from a handler someone else added.
327
+ const DISPATCH_PARENT_GUARDS = new WeakSet();
328
+
306
329
  function spawnWithOutputChunks(executable, args, options = {}) {
307
330
  const {
308
331
  encoding,
@@ -311,8 +334,13 @@ function spawnWithOutputChunks(executable, args, options = {}) {
311
334
  timeout,
312
335
  ...childOptions
313
336
  } = options;
337
+ // detached makes the child its own process-group leader, so a time cap can
338
+ // stop the engine the shell launched and not just the shell. Windows has no
339
+ // process groups and keeps the direct child kill.
340
+ const processGroups = process.platform !== 'win32';
314
341
  const child = spawn(executable, args, {
315
342
  ...childOptions,
343
+ detached: processGroups,
316
344
  stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
317
345
  });
318
346
  if (input != null && child.stdin) {
@@ -320,6 +348,53 @@ function spawnWithOutputChunks(executable, args, options = {}) {
320
348
  child.stdin.end(input);
321
349
  }
322
350
 
351
+ const killChild = () => {
352
+ if (!processGroups) {
353
+ try { child.kill('SIGKILL'); } catch {}
354
+ return;
355
+ }
356
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch {} }
357
+ };
358
+ // If this process dies while the child runs, take the child's group down
359
+ // with it so an interrupted dispatch does not orphan the engine. A detached
360
+ // child leads its own group, so the terminal's Ctrl-C never reaches it, and
361
+ // Node's default signal exit skips 'exit' listeners. Guard the termination
362
+ // signals too: kill the group, drop every listener this dispatch added,
363
+ // then re-raise the same signal so the parent still dies the way it would
364
+ // have. Re-raise only when nothing but dispatch guards listened when this
365
+ // one installed, and nothing listens now: a handler added with
366
+ // process.once has already removed itself by the time this runs, and its
367
+ // async cleanup owns exiting. Other dispatches' guards do not count at
368
+ // install, so the last of several running dispatches still re-raises.
369
+ const killGroupOnParentExit = () => {
370
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
371
+ };
372
+ const parentGuards = [['exit', killGroupOnParentExit]];
373
+ const othersAtInstall = new Map();
374
+ const setParentGuards = (install) => {
375
+ for (const [event, handler] of parentGuards) {
376
+ if (install) {
377
+ othersAtInstall.set(event, process.listeners(event).filter((fn) => !DISPATCH_PARENT_GUARDS.has(fn)).length);
378
+ DISPATCH_PARENT_GUARDS.add(handler);
379
+ process.on(event, handler);
380
+ } else {
381
+ process.removeListener(event, handler);
382
+ }
383
+ }
384
+ };
385
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
386
+ parentGuards.push([signal, () => {
387
+ killGroupOnParentExit();
388
+ setParentGuards(false);
389
+ if (othersAtInstall.get(signal) === 0 && process.listenerCount(signal) === 0) {
390
+ process.kill(process.pid, signal);
391
+ }
392
+ }]);
393
+ }
394
+ if (processGroups && typeof child.pid === 'number') {
395
+ setParentGuards(true);
396
+ }
397
+
323
398
  const stdoutChunks = [];
324
399
  const stderrChunks = [];
325
400
  let spawnError = null;
@@ -337,11 +412,12 @@ function spawnWithOutputChunks(executable, args, options = {}) {
337
412
  ? setTimeout(() => {
338
413
  timeoutError = new Error('spawn ETIMEDOUT');
339
414
  timeoutError.code = 'ETIMEDOUT';
340
- try { child.kill('SIGKILL'); } catch {}
415
+ killChild();
341
416
  }, Number(timeout))
342
417
  : null;
343
418
  child.once('close', (status, signal) => {
344
419
  if (timer) clearTimeout(timer);
420
+ setParentGuards(false);
345
421
  const encode = (chunks) => {
346
422
  const output = Buffer.concat(chunks);
347
423
  return encoding ? output.toString(encoding) : output;
@@ -761,7 +837,7 @@ async function dispatchEntryWithRestaff({
761
837
  // injectable for tests. `prompt` is injectable too: a caller-supplied prompt
762
838
  // (e.g. `atris engine dispatch --prompt-file`) skips the generated
763
839
  // buildFleetPrompt text entirely.
764
- function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), timeoutMs = null, runner = null, prompt: promptOverride = '', yolo = false, sealed = false, briefId = '', skipBriefCapture = false, environment = null, allowedTools = FLEET_ALLOWED_TOOLS, liveLogPath = '' }) {
840
+ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), timeoutMs = null, runner = null, prompt: promptOverride = '', yolo = false, sealed = false, briefId = '', skipBriefCapture = false, environment = null, allowedTools = FLEET_ALLOWED_TOOLS, liveLogPath = '', model = '', effort = '', maxSeconds = 0 }) {
765
841
  assertIsolatedWorktree(worktreePath, root);
766
842
  const prompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo });
767
843
  let capturedBriefId = briefId;
@@ -788,10 +864,17 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
788
864
  allowedTools,
789
865
  watchdogPath,
790
866
  watchdogReceiptPath: watchdogArtifact,
867
+ model,
868
+ effort,
869
+ maxSeconds,
791
870
  });
792
- const dispatchTimeoutMs = timeoutMs === null || timeoutMs === undefined
793
- ? (engine === 'codex' ? CODEX_DISPATCH_TIMEOUT_MS : DEFAULT_DISPATCH_TIMEOUT_MS)
794
- : timeoutMs;
871
+ // A roster time cap replaces the default backstop. Codex keeps a minute of
872
+ // slack past its watchdog so the watchdog's own receipt gets written.
873
+ const cap = capSeconds(maxSeconds);
874
+ const defaultTimeoutMs = cap
875
+ ? cap * 1000 + (engine === 'codex' ? 60000 : 0)
876
+ : (engine === 'codex' ? CODEX_DISPATCH_TIMEOUT_MS : DEFAULT_DISPATCH_TIMEOUT_MS);
877
+ const dispatchTimeoutMs = timeoutMs === null || timeoutMs === undefined ? defaultTimeoutMs : timeoutMs;
795
878
  const exec = runner || ((cmd) => {
796
879
  const childEnv = sealed && environment
797
880
  ? { ...environment }
@@ -2130,6 +2213,7 @@ async function runIndependentValidator({
2130
2213
  validatorEngines,
2131
2214
  validatorDispatcher = null,
2132
2215
  stateInspector = reviewWorktreeSnapshot,
2216
+ validatorModels = null,
2133
2217
  }) {
2134
2218
  const candidates = [...new Set((validatorEngines || []).map((value) => String(value || '').trim()).filter(Boolean))]
2135
2219
  .filter((name) => RUNNER_PROFILE_DEFS[name] && name !== executorEngine);
@@ -2152,6 +2236,7 @@ async function runIndependentValidator({
2152
2236
  };
2153
2237
  }
2154
2238
  const dispatch = validatorDispatcher || ((entry) => Promise.resolve(dispatchToEngine({
2239
+ ...validatorPin(validatorModels, entry.engine),
2155
2240
  task,
2156
2241
  engine: entry.engine,
2157
2242
  worktreePath,
@@ -2828,6 +2913,13 @@ async function runDispatchFlight({
2828
2913
  validatorDispatcher = null,
2829
2914
  validatorStateInspector = null,
2830
2915
  scoutAsk = null,
2916
+ // Pinned model, effort, and time cap for the requested engine only; a
2917
+ // restaffed fallback engine runs on its own defaults. validatorModels maps
2918
+ // a validator engine to its model, or to { model, effort, max_seconds }.
2919
+ model = '',
2920
+ effort = '',
2921
+ maxSeconds = 0,
2922
+ validatorModels = null,
2831
2923
  } = {}) {
2832
2924
  if (!engine) throw new Error('runDispatchFlight: engine is required');
2833
2925
  if (!DISPATCH_CAPABLE.includes(engine)) {
@@ -2985,6 +3077,9 @@ async function runDispatchFlight({
2985
3077
  briefId: entry.brief_id,
2986
3078
  skipBriefCapture: true,
2987
3079
  liveLogPath,
3080
+ model: entry.engine === engine ? model : '',
3081
+ effort: entry.engine === engine ? effort : '',
3082
+ maxSeconds: entry.engine === engine ? maxSeconds : 0,
2988
3083
  }));
2989
3084
  }));
2990
3085
  const restaffState = { used: false };
@@ -3272,6 +3367,7 @@ async function runDispatchFlight({
3272
3367
  validatorEngines,
3273
3368
  validatorDispatcher,
3274
3369
  stateInspector: validatorStateInspector || reviewWorktreeSnapshot,
3370
+ validatorModels,
3275
3371
  });
3276
3372
  validatorResult = validation.validator_result;
3277
3373
  flight.validator_result = validatorResult;
package/lib/host.js ADDED
@@ -0,0 +1,336 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+
7
+ const DEFAULT_CONFIG = Object.freeze({ cadence_days: 3, question_expiry_days: 7, pause_after_unanswered: 2, intro_expiry_days: 7, intros_per_person_per_30d: 1 });
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'];
10
+ const CARD_KEYS = ['into_lately', 'going_for', 'great_at', 'wants_to_meet', 'worth_celebrating'];
11
+ const CARD_TITLES = ['Into lately', 'Going for', 'Great at', 'Wants to meet', 'Worth celebrating'];
12
+
13
+ function fail(message) { throw new Error(message); }
14
+ function required(value, label) { if (typeof value !== 'string' || !value.trim()) fail(`${label} is required`); return value.trim(); }
15
+ function singleLine(value, label) { const text = required(value, label); if (/[\r\n]/.test(text)) fail(`${label} must be one line`); return text; }
16
+ 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
+ function fileId(id) { return encodeURIComponent(safeId(id)); }
18
+ function days(iso, count) { return new Date(Date.parse(iso) + count * 86400000).toISOString(); }
19
+ function nowIso(now) { const date = new Date(now || Date.now()); if (!Number.isFinite(date.getTime())) fail('invalid time'); return date.toISOString(); }
20
+ function privateDir(root) { if (!fs.existsSync(path.join(root, 'atris'))) fail('run from a workspace containing atris/'); return path.join(root, 'atris', 'team', 'host', 'private'); }
21
+ function personPath(root, id) { return path.join(privateDir(root), 'people', `${fileId(id)}.md`); }
22
+ function cardPath(root, id) { return path.join(root, 'atris', 'wiki', 'people', `${fileId(id)}.md`); }
23
+ function introDir(root) { return path.join(privateDir(root), 'intros'); }
24
+ function outboxDir(root) { return path.join(privateDir(root), 'outbox'); }
25
+ function readText(file, fallback = null) { if (!fs.existsSync(file)) return fallback; return fs.readFileSync(file, 'utf8'); }
26
+ function atomic(file, content) {
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
29
+ try { fs.writeFileSync(temp, content, { flag: 'wx' }); fs.renameSync(temp, file); }
30
+ finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
31
+ }
32
+ function json(file, fallback = null) {
33
+ const raw = readText(file);
34
+ if (raw === null) return fallback;
35
+ try { return JSON.parse(raw); } catch { fail(`malformed json: ${file}`); }
36
+ }
37
+ function putJson(file, value) { atomic(file, `${JSON.stringify(value, null, 2)}\n`); }
38
+ function rows(file) {
39
+ const raw = readText(file, '');
40
+ return raw.split('\n').filter(Boolean).map((line) => {
41
+ try { return JSON.parse(line); } catch { fail(`malformed jsonl: ${file}`); }
42
+ });
43
+ }
44
+ function putRows(file, entries) { atomic(file, entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '')); }
45
+ function record(file, keys) {
46
+ const raw = readText(file);
47
+ if (raw === null) fail(`missing record: ${file}`);
48
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
49
+ if (!match) fail(`malformed record: ${file}`);
50
+ const data = {};
51
+ for (const line of match[1].split('\n')) {
52
+ const part = line.match(/^([a-z_]+): (.+)$/);
53
+ if (!part || !keys.includes(part[1]) || Object.hasOwn(data, part[1])) fail(`malformed record: ${file}`);
54
+ try { data[part[1]] = JSON.parse(part[2]); } catch { fail(`malformed record: ${file}`); }
55
+ }
56
+ if (keys.some((key) => !Object.hasOwn(data, key))) fail(`malformed record: ${file}`);
57
+ return { data, body: match[2] };
58
+ }
59
+ function putRecord(file, keys, data, body = '') {
60
+ atomic(file, `---\n${keys.map((key) => `${key}: ${JSON.stringify(data[key])}`).join('\n')}\n---\n${body}`);
61
+ }
62
+ function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
63
+ function locked(root, work) {
64
+ const dir = privateDir(root);
65
+ fs.mkdirSync(dir, { recursive: true });
66
+ const lock = path.join(dir, '.lock');
67
+ const deadline = Date.now() + 10000;
68
+ while (true) {
69
+ try { fs.mkdirSync(lock); break; }
70
+ catch (error) {
71
+ if (error.code !== 'EEXIST') throw error;
72
+ try { if (Date.now() - fs.statSync(lock).mtimeMs > 600000) fs.rmSync(lock, { recursive: true, force: true }); }
73
+ catch (statError) { if (statError.code !== 'ENOENT') throw statError; }
74
+ if (Date.now() >= deadline) fail('host is busy; retry');
75
+ sleep(20 + Math.floor(Math.random() * 50));
76
+ }
77
+ }
78
+ try { return work(); } finally { fs.rmSync(lock, { recursive: true, force: true }); }
79
+ }
80
+ function config(root) {
81
+ const file = path.join(privateDir(root), 'config.json');
82
+ if (!fs.existsSync(file)) putJson(file, DEFAULT_CONFIG);
83
+ 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;
86
+ }
87
+ function people(root) {
88
+ const dir = path.join(privateDir(root), 'people');
89
+ 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));
91
+ }
92
+ function person(root, id) { return record(personPath(root, id), PERSON_KEYS); }
93
+ function savePerson(root, entry) { entry.data.revision += 1; putRecord(personPath(root, entry.data.id), PERSON_KEYS, entry.data, entry.body); }
94
+ function intros(root) {
95
+ const dir = introDir(root);
96
+ 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) }));
98
+ }
99
+ function queue(root, to, kind, text, at, attemptId = null) {
100
+ const recipient = person(root, to).data;
101
+ const message = { id: crypto.randomUUID(), to, door: recipient.door, kind, text, created_at: at, state: 'queued' };
102
+ if (attemptId) message.attempt_id = attemptId;
103
+ putJson(path.join(outboxDir(root), `${message.id}.json`), message);
104
+ return message;
105
+ }
106
+ function messages(root) {
107
+ const dir = outboxDir(root);
108
+ if (!fs.existsSync(dir)) return [];
109
+ return fs.readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => json(path.join(dir, name))).sort((a, b) => a.created_at.localeCompare(b.created_at));
110
+ }
111
+ function dropQueued(root, predicate) {
112
+ for (const message of messages(root)) if (message.state === 'queued' && predicate(message)) fs.unlinkSync(path.join(outboxDir(root), `${message.id}.json`));
113
+ }
114
+ function closeIntro(root, entry, reason) {
115
+ entry.data.state = 'closed';
116
+ entry.data.closed_reason = reason;
117
+ putRecord(entry.file, INTRO_KEYS, entry.data, entry.body);
118
+ dropQueued(root, (message) => message.kind === 'intro_ask' && message.attempt_id === entry.data.attempt_id);
119
+ }
120
+ function closeFor(root, id, reason) {
121
+ for (const entry of intros(root)) if (entry.data.state === 'pending' && [entry.data.a, entry.data.b].includes(id)) closeIntro(root, entry, reason);
122
+ }
123
+ function expireIntros(root, at) {
124
+ for (const entry of intros(root)) if (entry.data.state === 'pending' && entry.data.expires_at <= at) closeIntro(root, entry, 'expired');
125
+ }
126
+ function expireQuestions(root, at, settings) {
127
+ for (const entry of people(root)) {
128
+ const p = entry.data;
129
+ if (p.status !== 'active' || !p.pending_question_id || p.pending_expires_at > at) continue;
130
+ p.pending_question_id = null; p.pending_question = null; p.pending_expires_at = null;
131
+ p.unanswered_count += 1;
132
+ dropQueued(root, (message) => message.to === p.id && message.kind === 'question');
133
+ if (p.unanswered_count >= settings.pause_after_unanswered) {
134
+ p.status = 'paused';
135
+ closeFor(root, p.id, 'pause');
136
+ dropQueued(root, (message) => message.to === p.id);
137
+ }
138
+ savePerson(root, entry);
139
+ }
140
+ }
141
+ function cardText(name, team, values) {
142
+ 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
+ }
144
+ function cardValues(raw) {
145
+ const values = {};
146
+ for (let i = 0; i < CARD_KEYS.length; i += 1) {
147
+ const start = raw.indexOf(`## ${CARD_TITLES[i]}\n\n`);
148
+ if (start < 0) fail('malformed published card');
149
+ const begin = start + `## ${CARD_TITLES[i]}\n\n`.length;
150
+ const end = raw.indexOf('\n\n## ', begin);
151
+ values[CARD_KEYS[i]] = raw.slice(begin, end < 0 ? undefined : end).trim();
152
+ }
153
+ return values;
154
+ }
155
+ function published(root, id) { const raw = readText(cardPath(root, id)); if (raw === null) fail(`missing published card for ${id}`); return raw; }
156
+ function pair(a, b) { return [safeId(a), safeId(b)].sort(); }
157
+
158
+ function hostAction(root, command, arg = {}) {
159
+ if (command === 'view') {
160
+ if (arg.as && arg.team) fail('choose --as or --team');
161
+ if (arg.as) {
162
+ const own = person(root, arg.as);
163
+ return { cards: people(root).filter((entry) => entry.data.status !== 'left').map((entry) => ({ id: entry.data.id, card: published(root, entry.data.id) })), own: { ...own.data, body: own.body } };
164
+ }
165
+ if (arg.team) return { cards: people(root).filter((entry) => entry.data.status !== 'left' && entry.data.team === arg.team).map((entry) => ({ id: entry.data.id, card: published(root, entry.data.id) })) };
166
+ fail('view needs --as <id> or --team <team>');
167
+ }
168
+ return locked(root, () => {
169
+ const settings = config(root);
170
+ const at = nowIso(arg.now);
171
+ expireIntros(root, at);
172
+ expireQuestions(root, at, settings);
173
+ if (command === 'outbox') return messages(root).filter((message) => message.state === 'queued');
174
+ if (command === 'join') {
175
+ const id = safeId(arg.id);
176
+ if (rows(path.join(privateDir(root), 'forgotten.jsonl')).some((row) => row.id === id)) fail('this id was forgotten and cannot rejoin');
177
+ if (fs.existsSync(personPath(root, id))) fail('person already joined');
178
+ 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: [] };
180
+ if (data.manager_id) safeId(data.manager_id);
181
+ putRecord(personPath(root, id), PERSON_KEYS, data, '');
182
+ if (!fs.existsSync(cardPath(root, id))) atomic(cardPath(root, id), cardText(name, data.team, {}));
183
+ queue(root, id, 'welcome', `Hi ${name}. I am the Host. I will ask a fun question now and then to get to know you. The human host can see your answers. You can pause or leave anytime.`, at);
184
+ return { id, revision: 1, due: true };
185
+ }
186
+ if (['leave', 'pause', 'resume'].includes(command)) {
187
+ const entry = person(root, arg.id);
188
+ entry.data.status = command === 'resume' ? 'active' : command === 'leave' ? 'left' : 'paused';
189
+ if (command !== 'resume') { closeFor(root, arg.id, command); dropQueued(root, (message) => message.to === arg.id); }
190
+ if (command === 'resume') { entry.data.next_question_at = at; entry.data.unanswered_count = 0; }
191
+ savePerson(root, entry);
192
+ return { id: arg.id, status: entry.data.status };
193
+ }
194
+ if (command === 'forget') {
195
+ const id = safeId(arg.id);
196
+ person(root, id);
197
+ for (const entry of people(root)) if (entry.data.id !== id && entry.data.manager_id === id) { entry.data.manager_id = null; savePerson(root, entry); }
198
+ const affected = intros(root).filter((entry) => [entry.data.a, entry.data.b].includes(id));
199
+ for (const entry of affected) fs.unlinkSync(entry.file);
200
+ const attempts = new Set(affected.map((entry) => entry.data.attempt_id));
201
+ for (const message of messages(root)) if (message.to === id || attempts.has(message.attempt_id)) fs.unlinkSync(path.join(outboxDir(root), `${message.id}.json`));
202
+ const linkFile = path.join(privateDir(root), 'links.jsonl');
203
+ putRows(linkFile, rows(linkFile).filter((link) => link.a !== id && link.b !== id));
204
+ fs.unlinkSync(personPath(root, id));
205
+ if (fs.existsSync(cardPath(root, id))) fs.unlinkSync(cardPath(root, id));
206
+ const marker = path.join(privateDir(root), 'forgotten.jsonl');
207
+ putRows(marker, [...rows(marker), { id }]);
208
+ // The prior room summary may name this person.
209
+ const room = path.join(privateDir(root), 'room.md');
210
+ if (fs.existsSync(room)) fs.unlinkSync(room);
211
+ return { id, forgotten: true };
212
+ }
213
+ 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) }));
215
+ }
216
+ if (command === 'ask') {
217
+ const entry = person(root, arg.id);
218
+ if (entry.data.status !== 'active' || entry.data.pending_question_id) fail('person is not ready for a question');
219
+ if (entry.data.next_question_at > at) fail('question is not due yet');
220
+ const question = required(arg.question, 'question');
221
+ entry.data.pending_question_id = crypto.randomUUID();
222
+ entry.data.pending_question = question;
223
+ entry.data.pending_expires_at = days(at, settings.question_expiry_days);
224
+ entry.data.next_question_at = days(at, settings.cadence_days);
225
+ savePerson(root, entry);
226
+ const message = queue(root, arg.id, 'question', question, at);
227
+ return { id: arg.id, question_id: entry.data.pending_question_id, message_id: message.id, revision: entry.data.revision };
228
+ }
229
+ if (command === 'receive') {
230
+ const eventId = required(arg.eventId, 'event id');
231
+ const sender = required(arg.from, 'from');
232
+ const matches = people(root).filter((entry) => entry.data.id === sender || entry.data.door === sender);
233
+ if (matches.length !== 1) fail('unknown or ambiguous sender');
234
+ const entry = matches[0];
235
+ if (entry.data.processed_events.includes(eventId)) return { id: entry.data.id, duplicate: true };
236
+ if (entry.data.status !== 'active') fail('person is not active');
237
+ const answer = required(arg.text, 'text');
238
+ 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);
243
+ 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);
252
+ 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 }]);
254
+ 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) {
257
+ entry.body += `## ${at}\n\nQ: ${entry.data.pending_question}\n\nA: ${answer}\n\n`;
258
+ entry.data.pending_question_id = null; entry.data.pending_question = null; entry.data.pending_expires_at = null; entry.data.unanswered_count = 0;
259
+ 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'; }
263
+ }
264
+ savePerson(root, entry);
265
+ return { id: entry.data.id, kind, revision: entry.data.revision };
266
+ }
267
+ if (command === 'card') {
268
+ const entry = person(root, arg.id);
269
+ if (Number(arg.expectedRevision) !== entry.data.revision) fail('stale card revision');
270
+ const patch = json(path.resolve(root, required(arg.patch, 'patch file')));
271
+ if (!patch || Array.isArray(patch) || typeof patch !== 'object' || Object.keys(patch).some((key) => !CARD_KEYS.includes(key) || typeof patch[key] !== 'string' || /^#{1,6}\s/m.test(patch[key]))) fail('card patch may contain only the five text fields without headings');
272
+ const current = cardValues(published(root, arg.id));
273
+ atomic(cardPath(root, arg.id), cardText(entry.data.name, entry.data.team, { ...current, ...patch }));
274
+ savePerson(root, entry);
275
+ return { id: arg.id, revision: entry.data.revision };
276
+ }
277
+ if (command === 'link') {
278
+ const [a, b] = pair(arg.a, arg.b);
279
+ if (a === b) fail('a person cannot link to themselves');
280
+ person(root, a); person(root, b);
281
+ if (!['channel', 'answer', 'card', 'intro'].includes(arg.source)) fail('invalid link source');
282
+ const evidence = required(arg.evidence, 'evidence');
283
+ const file = path.join(privateDir(root), 'links.jsonl');
284
+ const links = rows(file);
285
+ if (!links.some((link) => link.a === a && link.b === b && link.source === arg.source && link.evidence === evidence)) links.push({ a, b, source: arg.source, evidence, at });
286
+ putRows(file, links);
287
+ return { a, b, linked: true };
288
+ }
289
+ if (command === 'propose') {
290
+ const [a, b] = pair(arg.a, arg.b);
291
+ if (a === b) fail('cannot introduce a person to themselves');
292
+ const one = person(root, a).data, two = person(root, b).data;
293
+ if (one.status !== 'active' || two.status !== 'active') fail('both people must be active');
294
+ if (one.manager_id === b || two.manager_id === a) fail('manager and report cannot be introduced');
295
+ if (rows(path.join(privateDir(root), 'links.jsonl')).some((link) => link.a === a && link.b === b && link.source !== 'card')) fail('people are already linked');
296
+ const history = intros(root);
297
+ if (history.some((entry) => entry.data.a === a && entry.data.b === b && entry.data.closed_reason === 'declined')) fail('this pair declined before');
298
+ 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');
299
+ 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');
302
+ 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 };
304
+ const file = path.join(introDir(root), `${fileId(a)}--${fileId(b)}--${attempt}.md`);
305
+ putRecord(file, INTRO_KEYS, data, wording);
306
+ 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.`;
307
+ queue(root, a, 'intro_ask', ask, at, attempt);
308
+ const otherAsk = `I thought you and ${one.name} might enjoy ${activity}. ${reason} Would you like an introduction? Reply yes or no.`;
309
+ queue(root, b, 'intro_ask', otherAsk, at, attempt);
310
+ return { a, b, attempt_id: attempt, state: 'pending' };
311
+ }
312
+ if (command === 'sent') {
313
+ const id = required(arg.id, 'message id');
314
+ if (!/^[a-f0-9-]{36}$/.test(id)) fail('invalid message id');
315
+ const file = path.join(outboxDir(root), `${id}.json`);
316
+ const message = json(file);
317
+ if (!message) fail('unknown message');
318
+ message.state = 'sent'; putJson(file, message);
319
+ return { id, state: 'sent' };
320
+ }
321
+ if (command === 'room') {
322
+ const roster = people(root);
323
+ const links = rows(path.join(privateDir(root), 'links.jsonl'));
324
+ 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`;
329
+ atomic(path.join(privateDir(root), 'room.md'), content);
330
+ return { text: content };
331
+ }
332
+ fail('unknown host command');
333
+ });
334
+ }
335
+
336
+ module.exports = { hostAction, DEFAULT_CONFIG };
@@ -5,7 +5,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
- 'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'playbook', 'feed', 'brief', 'social', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
8
+ 'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
11
11
  // adjacent transposition (e.g. "taks" -> "task") as a single edit, since