atris 3.58.7 → 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.
- package/AGENTS.md +3 -2
- package/GETTING_STARTED.md +2 -2
- package/PERSONA.md +11 -11
- package/README.md +1 -1
- package/atris/GEMINI.md +7 -6
- package/atris/PERSONA.md +4 -4
- package/atris/features/README.md +77 -6
- package/atris/skills/README.md +44 -15
- package/atris/skills/aeo/SKILL.md +10 -10
- package/atris/skills/atris/SKILL.md +1 -1
- package/atris/skills/engines/SKILL.md +69 -50
- package/atris/skills/meta/SKILL.md +4 -4
- package/bin/atris.js +8 -6
- package/commands/aeo.js +9 -12
- package/commands/analytics.js +8 -11
- package/commands/autopilot.js +79 -8
- package/commands/avail.js +1 -1
- package/commands/brain.js +3 -1
- package/commands/business.js +13 -8
- package/commands/clean.js +17 -0
- package/commands/close.js +2 -1
- package/commands/computer.js +15 -10
- package/commands/context-sync.js +3 -3
- package/commands/doc-health.js +55 -2
- package/commands/engine.js +384 -4
- package/commands/experiments.js +13 -4
- package/commands/host.js +53 -0
- package/commands/improve.js +1 -1
- package/commands/integrations.js +2 -5
- package/commands/learn.js +1 -1
- package/commands/member.js +231 -53
- package/commands/mission.js +129 -19
- package/commands/one-lap.js +58 -3
- package/commands/pack.js +2 -2
- package/commands/probe.js +11 -5
- package/commands/pull.js +1 -1
- package/commands/push.js +1 -1
- package/commands/rainmaker.js +3 -16
- package/commands/run.js +4 -1
- package/commands/scout.js +1 -1
- package/commands/search.js +5 -3
- package/commands/spaceship.js +1 -1
- package/commands/stream.js +27 -2
- package/commands/study.js +15 -9
- package/commands/sync.js +1 -1
- package/commands/task.js +13 -2
- package/commands/unknowns.js +5 -0
- package/commands/verify.js +12 -4
- package/commands/visualize.js +20 -3
- package/commands/workflow.js +23 -7
- package/lib/codex-flight.js +28 -5
- package/lib/dispatch-scout.js +31 -22
- package/lib/engine-registry.js +1482 -13
- package/lib/feedback-triage.js +2 -0
- package/lib/fleet.js +111 -12
- package/lib/host.js +336 -0
- package/lib/improve-vitals-html.js +3 -2
- package/lib/known-commands.js +1 -1
- package/lib/lesson-contradiction.js +2 -2
- package/lib/map-refs.js +226 -0
- package/lib/member-engine.js +253 -0
- package/lib/mission-room.js +4 -4
- package/lib/noninteractive.js +15 -4
- package/lib/policy-lessons.js +1 -1
- package/lib/receipt-block.js +137 -28
- package/lib/roster-markdown.js +206 -0
- package/lib/roster-models.js +179 -0
- package/lib/runner-command.js +95 -15
- package/lib/scorecard.js +4 -4
- package/lib/self-drive.js +9 -1
- package/lib/sleep-sync.js +11 -0
- package/lib/state-detection.js +28 -9
- package/lib/task-db.js +3 -2
- package/lib/todo-fallback.js +1 -1
- package/lib/wish-audit.js +10 -6
- package/lib/wish-delegate.js +29 -5
- package/mcp/atris-mcp/index.mjs +77 -24
- package/package.json +2 -5
- package/scripts/det/det.js +2 -2
- package/scripts/det/pr-description.js +2 -2
- package/scripts/det/test.js +2 -2
- package/templates/members/host/MEMBER.md +23 -0
- package/templates/members/host/MISSION.md +11 -0
- package/templates/members/host/SOUL.md +7 -0
- package/templates/members/host/context/why.md +3 -0
- package/templates/members/host/goals.json +5 -0
- package/templates/members/host/goals.md +3 -0
- package/templates/members/host/skills/host/SKILL.md +13 -0
- package/utils/api.js +1 -1
- package/utils/auth.js +17 -7
- package/utils/backend-root.js +31 -0
- package/utils/config.js +5 -4
- package/utils/owner-identity.js +61 -0
- package/atris/policies/LESSONS.md +0 -13
- package/atris/skills/autopilot/atris-autopilot.md +0 -71
package/lib/feedback-triage.js
CHANGED
|
@@ -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
|
@@ -33,8 +33,11 @@ const {
|
|
|
33
33
|
const { listWorktrees, defaultMainlineBase } = require('../commands/worktree');
|
|
34
34
|
const { isConductorStatusLine } = require('./conductor-artifacts');
|
|
35
35
|
const { matchLessons } = require('./lesson-preflight');
|
|
36
|
+
const { sleepSync } = require('./sleep-sync');
|
|
37
|
+
const { ownerIdentity } = require('../utils/owner-identity');
|
|
36
38
|
const { matchTaste } = require('./taste-lessons');
|
|
37
39
|
const { buildVerifiedScoutPack, appendVerifiedScoutPack } = require('./dispatch-scout');
|
|
40
|
+
const { mapNotesPath } = require('./map-refs');
|
|
38
41
|
const {
|
|
39
42
|
applySecretGrantEnvironment,
|
|
40
43
|
buildGatewaySupervisorScript,
|
|
@@ -108,7 +111,9 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
|
|
|
108
111
|
lines.push(`Check: ${check}`, '');
|
|
109
112
|
lines.push(
|
|
110
113
|
'Rules:',
|
|
111
|
-
|
|
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.',
|
|
112
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.',
|
|
113
118
|
'- Run git status first. Stage ONLY files you changed. Never revert or touch files another agent modified.',
|
|
114
119
|
'- Include or update a focused regression test; run it with npm run test:fast && node --test <focused files> before committing.',
|
|
@@ -203,21 +208,37 @@ function assertIsolatedWorktree(worktreePath, root = process.cwd()) {
|
|
|
203
208
|
return { worktreePath: resolvedWorktree, primaryRoot };
|
|
204
209
|
}
|
|
205
210
|
|
|
206
|
-
|
|
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) {
|
|
207
227
|
// Codex hangs forever at startup when stdin is an open pipe (three real
|
|
208
228
|
// flights on 2026-08-11 sat 50-100 minutes at zero CPU). Force every fleet
|
|
209
229
|
// codex spawn through the silent-start / runtime-cap watchdog with stdin
|
|
210
230
|
// sealed, so orchestrator prompts cannot forget the convention.
|
|
211
231
|
const receiptArg = receiptPath ? ` --receipt ${shellSingleQuote(receiptPath)}` : '';
|
|
212
|
-
|
|
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`;
|
|
213
234
|
}
|
|
214
235
|
|
|
215
|
-
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 } = {}) {
|
|
216
237
|
if (!RUNNER_PROFILE_DEFS[engineName]) throw new Error(`unknown engine "${engineName}"`);
|
|
217
238
|
const prev = process.env.ATRIS_RUNNER_PROFILE;
|
|
218
239
|
process.env.ATRIS_RUNNER_PROFILE = engineName;
|
|
219
240
|
try {
|
|
220
|
-
let cmd = buildRunnerCommand({ promptFile, allowedTools });
|
|
241
|
+
let cmd = buildRunnerCommand({ promptFile, allowedTools, ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
|
|
221
242
|
// devin's default is read-only for writes; fleet builds ALWAYS run in an
|
|
222
243
|
// isolated worktree, so the conductor grants write permission here and
|
|
223
244
|
// only here (the profile itself stays safe for non-worktree ticks).
|
|
@@ -241,7 +262,7 @@ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = fal
|
|
|
241
262
|
if (yolo && engineName === 'codex') cmd = cmd.replace(/\bexec\b/, `exec ${YOLO_ENGINE_FLAGS.codex}`);
|
|
242
263
|
if (yolo && (engineName === 'claude' || engineName === 'fable')) cmd = `${cmd} ${YOLO_ENGINE_FLAGS.claude}`;
|
|
243
264
|
if (yolo && engineName === 'commandcode') cmd = `${cmd} --yolo`;
|
|
244
|
-
if (engineName === 'codex') cmd = wrapCodexWithWatchdog(cmd, watchdogPath, watchdogReceiptPath);
|
|
265
|
+
if (engineName === 'codex') cmd = wrapCodexWithWatchdog(cmd, watchdogPath, watchdogReceiptPath, maxSeconds);
|
|
245
266
|
return cmd;
|
|
246
267
|
} finally {
|
|
247
268
|
if (prev === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
|
|
@@ -286,7 +307,7 @@ function terminateTrackedSandbox(stateFile, leaseFile) {
|
|
|
286
307
|
for (const pid of tracked.pids) {
|
|
287
308
|
try { process.kill(pid, signal); } catch {}
|
|
288
309
|
}
|
|
289
|
-
|
|
310
|
+
sleepSync(100);
|
|
290
311
|
}
|
|
291
312
|
}
|
|
292
313
|
|
|
@@ -301,6 +322,10 @@ function terminateSameSandboxProfile(executable, args, options) {
|
|
|
301
322
|
});
|
|
302
323
|
}
|
|
303
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
|
+
|
|
304
329
|
function spawnWithOutputChunks(executable, args, options = {}) {
|
|
305
330
|
const {
|
|
306
331
|
encoding,
|
|
@@ -309,8 +334,13 @@ function spawnWithOutputChunks(executable, args, options = {}) {
|
|
|
309
334
|
timeout,
|
|
310
335
|
...childOptions
|
|
311
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';
|
|
312
341
|
const child = spawn(executable, args, {
|
|
313
342
|
...childOptions,
|
|
343
|
+
detached: processGroups,
|
|
314
344
|
stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
315
345
|
});
|
|
316
346
|
if (input != null && child.stdin) {
|
|
@@ -318,6 +348,53 @@ function spawnWithOutputChunks(executable, args, options = {}) {
|
|
|
318
348
|
child.stdin.end(input);
|
|
319
349
|
}
|
|
320
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
|
+
|
|
321
398
|
const stdoutChunks = [];
|
|
322
399
|
const stderrChunks = [];
|
|
323
400
|
let spawnError = null;
|
|
@@ -335,11 +412,12 @@ function spawnWithOutputChunks(executable, args, options = {}) {
|
|
|
335
412
|
? setTimeout(() => {
|
|
336
413
|
timeoutError = new Error('spawn ETIMEDOUT');
|
|
337
414
|
timeoutError.code = 'ETIMEDOUT';
|
|
338
|
-
|
|
415
|
+
killChild();
|
|
339
416
|
}, Number(timeout))
|
|
340
417
|
: null;
|
|
341
418
|
child.once('close', (status, signal) => {
|
|
342
419
|
if (timer) clearTimeout(timer);
|
|
420
|
+
setParentGuards(false);
|
|
343
421
|
const encode = (chunks) => {
|
|
344
422
|
const output = Buffer.concat(chunks);
|
|
345
423
|
return encoding ? output.toString(encoding) : output;
|
|
@@ -759,7 +837,7 @@ async function dispatchEntryWithRestaff({
|
|
|
759
837
|
// injectable for tests. `prompt` is injectable too: a caller-supplied prompt
|
|
760
838
|
// (e.g. `atris engine dispatch --prompt-file`) skips the generated
|
|
761
839
|
// buildFleetPrompt text entirely.
|
|
762
|
-
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 }) {
|
|
763
841
|
assertIsolatedWorktree(worktreePath, root);
|
|
764
842
|
const prompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo });
|
|
765
843
|
let capturedBriefId = briefId;
|
|
@@ -786,10 +864,17 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
|
|
|
786
864
|
allowedTools,
|
|
787
865
|
watchdogPath,
|
|
788
866
|
watchdogReceiptPath: watchdogArtifact,
|
|
867
|
+
model,
|
|
868
|
+
effort,
|
|
869
|
+
maxSeconds,
|
|
789
870
|
});
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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;
|
|
793
878
|
const exec = runner || ((cmd) => {
|
|
794
879
|
const childEnv = sealed && environment
|
|
795
880
|
? { ...environment }
|
|
@@ -1381,6 +1466,7 @@ function reviewOnlyEngineEnvironment(worktreePath, options = {}) {
|
|
|
1381
1466
|
throw error;
|
|
1382
1467
|
}
|
|
1383
1468
|
const environment = {
|
|
1469
|
+
ATRIS_OWNER_NAMES: [...new Set(ownerIdentity().names)].join(','),
|
|
1384
1470
|
PATH: `${guardDir}${path.delimiter}${process.env.PATH || ''}`,
|
|
1385
1471
|
GIT_CONFIG_COUNT: '2',
|
|
1386
1472
|
GIT_CONFIG_KEY_0: 'remote.origin.pushurl',
|
|
@@ -2127,6 +2213,7 @@ async function runIndependentValidator({
|
|
|
2127
2213
|
validatorEngines,
|
|
2128
2214
|
validatorDispatcher = null,
|
|
2129
2215
|
stateInspector = reviewWorktreeSnapshot,
|
|
2216
|
+
validatorModels = null,
|
|
2130
2217
|
}) {
|
|
2131
2218
|
const candidates = [...new Set((validatorEngines || []).map((value) => String(value || '').trim()).filter(Boolean))]
|
|
2132
2219
|
.filter((name) => RUNNER_PROFILE_DEFS[name] && name !== executorEngine);
|
|
@@ -2149,6 +2236,7 @@ async function runIndependentValidator({
|
|
|
2149
2236
|
};
|
|
2150
2237
|
}
|
|
2151
2238
|
const dispatch = validatorDispatcher || ((entry) => Promise.resolve(dispatchToEngine({
|
|
2239
|
+
...validatorPin(validatorModels, entry.engine),
|
|
2152
2240
|
task,
|
|
2153
2241
|
engine: entry.engine,
|
|
2154
2242
|
worktreePath,
|
|
@@ -2825,6 +2913,13 @@ async function runDispatchFlight({
|
|
|
2825
2913
|
validatorDispatcher = null,
|
|
2826
2914
|
validatorStateInspector = null,
|
|
2827
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,
|
|
2828
2923
|
} = {}) {
|
|
2829
2924
|
if (!engine) throw new Error('runDispatchFlight: engine is required');
|
|
2830
2925
|
if (!DISPATCH_CAPABLE.includes(engine)) {
|
|
@@ -2982,6 +3077,9 @@ async function runDispatchFlight({
|
|
|
2982
3077
|
briefId: entry.brief_id,
|
|
2983
3078
|
skipBriefCapture: true,
|
|
2984
3079
|
liveLogPath,
|
|
3080
|
+
model: entry.engine === engine ? model : '',
|
|
3081
|
+
effort: entry.engine === engine ? effort : '',
|
|
3082
|
+
maxSeconds: entry.engine === engine ? maxSeconds : 0,
|
|
2985
3083
|
}));
|
|
2986
3084
|
}));
|
|
2987
3085
|
const restaffState = { used: false };
|
|
@@ -3269,6 +3367,7 @@ async function runDispatchFlight({
|
|
|
3269
3367
|
validatorEngines,
|
|
3270
3368
|
validatorDispatcher,
|
|
3271
3369
|
stateInspector: validatorStateInspector || reviewWorktreeSnapshot,
|
|
3370
|
+
validatorModels,
|
|
3272
3371
|
});
|
|
3273
3372
|
validatorResult = validation.validator_result;
|
|
3274
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 };
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { spawnSync } = require('child_process');
|
|
13
|
+
const { isOwner } = require('../utils/owner-identity');
|
|
13
14
|
|
|
14
15
|
const HTML_RELATIVE_PATH = path.join('.atris', 'state', 'improve-vitals.html');
|
|
15
16
|
|
|
@@ -51,8 +52,8 @@ function openLoops(cwd, now) {
|
|
|
51
52
|
try {
|
|
52
53
|
const close = require('../commands/close');
|
|
53
54
|
const flags = close.openFlags(cwd, { now });
|
|
54
|
-
const mine = flags.filter((f) =>
|
|
55
|
-
const theirs = flags.filter((f) =>
|
|
55
|
+
const mine = flags.filter((f) => isOwner(f.owner));
|
|
56
|
+
const theirs = flags.filter((f) => !isOwner(f.owner));
|
|
56
57
|
return { mine, theirs };
|
|
57
58
|
} catch {
|
|
58
59
|
return { mine: [], theirs: [] };
|
package/lib/known-commands.js
CHANGED
|
@@ -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
|