atris 3.64.0 → 3.66.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.
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ // atris hire "<name>" --job "<idea>" one call: member, inbox, allowance, nightly loop
4
+ // atris hires the morning check-in: one line per hire, then its 24h / 7d scoreboard
5
+ // Backend: POST /api/business/{id}/hire, GET /api/business/{id}/hires
6
+
7
+ const { apiRequestJson } = require('../utils/api');
8
+ const { NOT_LOGGED_IN } = require('../lib/developer-api');
9
+ const {
10
+ parseArgs, businessOrSay, loginToken, errorText, relativeTime, dollars, clip,
11
+ } = require('../lib/business-target');
12
+
13
+ const HIRE_FLAGS = { switches: ['outreach'], values: ['job', 'budget', 'schedule', 'timezone'] };
14
+
15
+ function hireHelp(log = console.log) {
16
+ log(`usage: atris hire "<name>" --job "<idea>" [--budget 50] [--outreach] [--schedule nightly] [--business <slug>] [--json]
17
+
18
+ hire an agent in one step. it gets a name, a job, its own email inbox,
19
+ a monthly spending allowance, and a work loop that runs overnight.
20
+
21
+ --job the idea it works on, in plain words (required)
22
+ --budget most it may ask you to approve per month, in dollars (default 0 = no spending)
23
+ you still approve every purchase yourself
24
+ --outreach mark it as doing outreach (recorded; it uses a normal inbox for now)
25
+ --schedule nightly (default, 2 AM your time), hourly, or a cron like "0 3 * * *"
26
+ --timezone defaults to this computer's timezone
27
+ --business which business; defaults to the business folder you are in
28
+
29
+ running it again with the same name is safe: you get the same hire back,
30
+ and anything missing (like the inbox) is filled in. --budget changes the allowance.
31
+
32
+ then each morning: atris hires`);
33
+ }
34
+
35
+ function hiresHelp(log = console.log) {
36
+ log(`usage: atris hires [--business <slug>] [--json]
37
+
38
+ the morning check-in: one line per hire with its email in the last day,
39
+ what it spent this month, and what its last overnight run did. under it,
40
+ its scoreboard for the last 24 hours and 7 days: emails sent, how many got
41
+ a reply from a person, bounces, and spend.`);
42
+ }
43
+
44
+ function localTimezone() {
45
+ try {
46
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
47
+ } catch {
48
+ return '';
49
+ }
50
+ }
51
+
52
+ function renderHire(data = {}, now = Date.now()) {
53
+ const m = data.member || {};
54
+ const lines = [data.created ? `Hired ${m.name}.` : `${m.name} was already hired. Same hire, nothing duplicated.`];
55
+ if (m.job) lines.push(`Job: ${clip(m.job, 200)}`);
56
+ if (data.inbox && data.inbox.address) lines.push(`Inbox: ${data.inbox.address}`);
57
+ const a = data.allowance || {};
58
+ lines.push(Number(a.monthly_usd) > 0
59
+ ? `Allowance: ${dollars(a.monthly_usd)} a month. You still approve every purchase.`
60
+ : 'Allowance: none. It cannot ask to spend money.');
61
+ if (a.note) lines.push(` ${a.note}`);
62
+ const s = data.schedule || {};
63
+ let when = s.label ? `Works ${s.label}.` : 'Works on its schedule.';
64
+ if (s.enabled === false) when += ' Asleep right now.';
65
+ else if (data.next_run_at) when += ` First run ${relativeTime(data.next_run_at, now)}.`;
66
+ lines.push(when);
67
+ if (data.outreach && data.outreach.requested) lines.push(`Outreach: ${data.outreach.note || 'recorded.'}`);
68
+ lines.push('', 'Check in each morning: atris hires');
69
+ return lines.join('\n');
70
+ }
71
+
72
+ function hireLine(h = {}, now = Date.now()) {
73
+ const parts = [];
74
+ if (h.status && h.status !== 'active') {
75
+ const why = { needs_inbox: 'paused, no inbox yet', needs_allowance: 'paused, allowance not set up', setting_up: 'still setting up',
76
+ pending_schedule: 'set up, nightly work not switched on yet (run the same hire again)' };
77
+ parts.push(why[h.status] || `paused (${h.status})`);
78
+ }
79
+ const mail = h.emails_last_24h;
80
+ if (mail) parts.push(`sent ${mail.sent}, got ${mail.received} emails today`);
81
+ else parts.push('no inbox');
82
+ const a = h.allowance || {};
83
+ if (Number(a.monthly_usd) > 0) {
84
+ parts.push(a.spent_this_month_usd == null
85
+ ? `${dollars(a.monthly_usd)} a month`
86
+ : `spent ${dollars(a.spent_this_month_usd)} of ${dollars(a.monthly_usd)}`);
87
+ }
88
+ if (h.last_run_at) {
89
+ const result = h.last_result ? `: ${clip(h.last_result, 80)}` : '';
90
+ parts.push(`last ran ${relativeTime(h.last_run_at, now)}${result}`);
91
+ } else if (h.schedule && h.schedule.enabled === false) {
92
+ parts.push('asleep, has not run');
93
+ } else if (h.next_run_at) {
94
+ parts.push(`first run ${relativeTime(h.next_run_at, now)}`);
95
+ } else {
96
+ parts.push('has not run yet');
97
+ }
98
+ return `${h.name}: ${parts.join(' · ')}`;
99
+ }
100
+
101
+ function windowText(label, w) {
102
+ if (!w || w.sent == null) return '';
103
+ const replies = Number(w.replies) || 0;
104
+ const value = w.reply_rate == null || w.reply_rate === '' ? NaN : Number(w.reply_rate);
105
+ const rate = Number.isFinite(value) ? ` (${Math.round(value * 100)}%)` : '';
106
+ const parts = [`sent ${w.sent}`, `${replies} ${replies === 1 ? 'reply' : 'replies'}${rate}`,
107
+ `${Number(w.bounces) || 0} bounced`];
108
+ if (w.spend_cents != null) parts.push(`spent ${dollars(Number(w.spend_cents) / 100)}`);
109
+ return `${label}: ${parts.join(', ')}`;
110
+ }
111
+
112
+ // The second line under a hire: how its mail is landing. Empty for an older backend.
113
+ function scoreLine(board, now = Date.now()) {
114
+ if (!board) return '';
115
+ const parts = [windowText('last 24h', board.last_24h), windowText('last 7 days', board.last_7d)].filter(Boolean);
116
+ if (!parts.length) return '';
117
+ if (board.last_action_at) parts.push(`last action ${relativeTime(board.last_action_at, now)}`);
118
+ if (board.capped) parts.push('counts cover only the newest mail');
119
+ return ` ${parts.join(' · ')}`;
120
+ }
121
+
122
+ function renderHires(data = {}, now = Date.now()) {
123
+ const rows = Array.isArray(data.hires) ? data.hires : [];
124
+ if (!rows.length) return 'No hires yet. Start one: atris hire "Maya" --job "grow the newsletter"';
125
+ return rows.map((h) => [hireLine(h, now), scoreLine(h.scoreboard, now)].filter(Boolean).join('\n')).join('\n');
126
+ }
127
+
128
+ function io(deps) {
129
+ return { log: deps.log || console.log, err: deps.err || console.error };
130
+ }
131
+
132
+ async function hireCommand(args = [], deps = {}) {
133
+ const out = io(deps);
134
+ const { flags, pos, error } = parseArgs(args, HIRE_FLAGS);
135
+ if (flags.help || pos[0] === 'help') { hireHelp(out.log); return 0; }
136
+ if (error) { out.err(`${error}. see: atris hire --help`); return 1; }
137
+
138
+ const name = pos.join(' ').trim();
139
+ const job = typeof flags.job === 'string' ? flags.job.trim() : '';
140
+ if (!name) { out.err('give the hire a name: atris hire "Maya" --job "<idea>"'); return 1; }
141
+ if (!job) { out.err('give the hire a job: --job "<the idea to grow>"'); return 1; }
142
+ const body = { name, job, outreach: flags.outreach === true };
143
+ if (flags.budget != null) {
144
+ const budget = Number(flags.budget);
145
+ if (!Number.isInteger(budget) || budget < 0 || budget > 1000) {
146
+ out.err('--budget must be whole dollars from 0 to 1000');
147
+ return 1;
148
+ }
149
+ body.monthly_allowance_usd = budget;
150
+ }
151
+ if (typeof flags.schedule === 'string') body.schedule = flags.schedule;
152
+ const tz = typeof flags.timezone === 'string' ? flags.timezone : (deps.timezone || localTimezone());
153
+ if (tz) body.timezone = tz;
154
+
155
+ const business = businessOrSay(flags, deps, out.err);
156
+ if (!business) return 1;
157
+ const token = await loginToken(deps);
158
+ if (!token) { out.err(NOT_LOGGED_IN); return 1; }
159
+
160
+ const request = deps.apiRequestJson || apiRequestJson;
161
+ const result = await request(`/business/${encodeURIComponent(business.id)}/hire`, {
162
+ method: 'POST', token, body, timeoutMs: 60000, retries: 0,
163
+ });
164
+ if (!result || !result.ok) { out.err(`Could not hire ${name}: ${errorText(result)}`); return 1; }
165
+ if (flags.json) out.log(JSON.stringify(result.data));
166
+ else out.log(renderHire(result.data, deps.now ? deps.now() : Date.now()));
167
+ return 0;
168
+ }
169
+
170
+ async function hiresCommand(args = [], deps = {}) {
171
+ const out = io(deps);
172
+ const { flags, pos, error } = parseArgs(args, {});
173
+ if (flags.help || pos[0] === 'help') { hiresHelp(out.log); return 0; }
174
+ if (error) { out.err(`${error}. see: atris hires --help`); return 1; }
175
+ if (pos.length) { out.err(`atris hires takes no other words (got "${pos[0]}"). see: atris hires --help`); return 1; }
176
+ const business = businessOrSay(flags, deps, out.err);
177
+ if (!business) return 1;
178
+ const token = await loginToken(deps);
179
+ if (!token) { out.err(NOT_LOGGED_IN); return 1; }
180
+
181
+ const request = deps.apiRequestJson || apiRequestJson;
182
+ const result = await request(`/business/${encodeURIComponent(business.id)}/hires`, { method: 'GET', token });
183
+ if (!result || !result.ok) { out.err(`Could not load hires: ${errorText(result)}`); return 1; }
184
+ if (flags.json) out.log(JSON.stringify(result.data));
185
+ else out.log(renderHires(result.data, deps.now ? deps.now() : Date.now()));
186
+ return 0;
187
+ }
188
+
189
+ module.exports = { hireCommand, hiresCommand, renderHire, renderHires, hireLine, scoreLine };
@@ -0,0 +1,367 @@
1
+ 'use strict';
2
+
3
+ // atris link "<thing a>" "<thing b>" [--context "..."] [--team] [--json]
4
+ //
5
+ // Asks what two things could become together. The answer is one short line
6
+ // naming the connection, a plain why, a bounded first thing to make, and the
7
+ // team member who should make it. The question goes to the roster's engine
8
+ // for the job (a `link` job when the roster has one, else the `build` lead),
9
+ // through the same read-only runner `atris engine ask` uses. Nothing is
10
+ // written except the run record line every ask leaves.
11
+
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const {
17
+ MAX_ASK_PROMPT_BYTES,
18
+ buildReadOnlyEngineInvocation,
19
+ runAskProcess,
20
+ runEngineAskJobs,
21
+ } = require('../lib/engine-ask');
22
+ const { isMemberParked } = require('../lib/member-park');
23
+
24
+ const LINK_TIMEOUT_MS = 180 * 1000;
25
+ const TEAM_FRESH_DAYS = 5;
26
+ const TEAM_NOTE_CHARS = 280;
27
+ const MAX_LINE_WORDS = 12;
28
+ const MEMBER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
29
+
30
+ const SYSTEM_PROMPT = [
31
+ 'You look at two things a person cares about and say what they could become together.',
32
+ '',
33
+ 'Rules:',
34
+ '- Use only facts stated in the two things, the context, and the team notes below. Do not read files, search, or run anything. Do not invent numbers, names, customers, or results.',
35
+ '- Plain words, the way a person talks. No em dashes. No hype or sales words; say the specific thing.',
36
+ `- line: one sentence of at most ${MAX_LINE_WORDS} words naming the connection.`,
37
+ '- why: two or three short sentences. Each one rests on something in the inputs.',
38
+ '- experiment.label: a small first thing to make, in a few words.',
39
+ '- experiment.scope: one sentence saying what it will do and what it will not do.',
40
+ '- by: one slug from the team list, the member best placed to make it, or null when none fits.',
41
+ '- hunch: true when the connection is a guess rather than grounded in both things; false when both things support it.',
42
+ '- If nothing specific stands out, answer {"line": null, "reason": "<one plain sentence>"} instead.',
43
+ '',
44
+ 'Answer with exactly one JSON object and nothing else:',
45
+ '{"line": "...", "why": "...", "experiment": {"label": "...", "scope": "..."}, "by": "slug-or-null", "hunch": false}',
46
+ ].join('\n');
47
+
48
+ function usage() {
49
+ return [
50
+ 'usage:',
51
+ ' atris link "<thing a>" "<thing b>" [--context "<text>"] [--team] [--json]',
52
+ '',
53
+ 'asks the roster\'s engine what these two things could become together.',
54
+ '',
55
+ 'options:',
56
+ ' --context <text> extra facts the answer may use',
57
+ ` --team read each member's now.md (fresh within ${TEAM_FRESH_DAYS} days, not parked) to pick who makes it`,
58
+ ' --json print the answer as json',
59
+ ].join('\n');
60
+ }
61
+
62
+ function parseLinkArgs(args = []) {
63
+ const things = [];
64
+ let context = '';
65
+ let team = false;
66
+ let json = false;
67
+ let help = false;
68
+ for (let index = 0; index < args.length; index += 1) {
69
+ const arg = String(args[index]);
70
+ if (arg === '--help' || arg === '-h') { help = true; continue; }
71
+ if (arg === '--json') { json = true; continue; }
72
+ if (arg === '--team') { team = true; continue; }
73
+ if (arg === '--context') {
74
+ if (index + 1 >= args.length) throw new Error('--context needs text');
75
+ context = String(args[index + 1]);
76
+ index += 1;
77
+ continue;
78
+ }
79
+ if (arg.startsWith('--context=')) { context = arg.slice('--context='.length); continue; }
80
+ if (arg.startsWith('--')) throw new Error(`unknown option ${arg}`);
81
+ things.push(arg.trim());
82
+ }
83
+ if (help) return { help: true, json };
84
+ if (things.length !== 2 || !things[0] || !things[1]) throw new Error(usage());
85
+ return { help: false, things, context: context.trim(), team, json };
86
+ }
87
+
88
+ // The roster's worker for this question: a `link` job when the owner made
89
+ // one, else whoever leads `build`.
90
+ function pickLinkWorker(report) {
91
+ const jobs = Array.isArray(report && report.jobs) ? report.jobs : [];
92
+ const row = jobs.find((entry) => entry && entry.job === 'link' && entry.engine)
93
+ || jobs.find((entry) => entry && entry.job === 'build' && entry.engine);
94
+ if (!row) return null;
95
+ return {
96
+ job: row.job,
97
+ engine: String(row.engine),
98
+ model: row.model ? String(row.model) : '',
99
+ effort: row.effort ? String(row.effort) : '',
100
+ };
101
+ }
102
+
103
+ function rosterMemberNames(report, root) {
104
+ const rows = Array.isArray(report && report.team) ? report.team : [];
105
+ const names = [];
106
+ for (const row of rows) {
107
+ const name = String((row && row.member) || '').trim();
108
+ if (!name || !MEMBER_NAME_RE.test(name) || names.includes(name)) continue;
109
+ if (isMemberParked(root, name)) continue;
110
+ names.push(name);
111
+ }
112
+ return names;
113
+ }
114
+
115
+ function oneLine(text) {
116
+ return String(text || '').replace(/\s+/g, ' ').trim();
117
+ }
118
+
119
+ // A member's now.md, trimmed to the first lines that say something: no
120
+ // heading marks, no "Updated:" stamp. Skipped when older than five days.
121
+ function teamNote(root, name, now = Date.now()) {
122
+ const file = path.join(root, 'atris', 'team', name, 'now.md');
123
+ let stat;
124
+ let text;
125
+ try {
126
+ stat = fs.statSync(file);
127
+ text = fs.readFileSync(file, 'utf8');
128
+ } catch {
129
+ return null;
130
+ }
131
+ if (now - stat.mtimeMs > TEAM_FRESH_DAYS * 86400000) return null;
132
+ const lines = text.split(/\r?\n/)
133
+ .map((line) => line.replace(/^#+\s*/, '').trim())
134
+ .filter((line) => line && line.toLowerCase() !== 'now' && !/^updated:/i.test(line));
135
+ const note = oneLine(lines.join(' '));
136
+ if (!note) return null;
137
+ return note.length > TEAM_NOTE_CHARS ? `${note.slice(0, TEAM_NOTE_CHARS - 3)}...` : note;
138
+ }
139
+
140
+ function buildLinkPrompt({ things, context, members, notes }) {
141
+ const parts = [
142
+ SYSTEM_PROMPT,
143
+ '',
144
+ `Thing A: ${oneLine(things[0])}`,
145
+ `Thing B: ${oneLine(things[1])}`,
146
+ ];
147
+ if (context) parts.push(`Context: ${oneLine(context)}`);
148
+ parts.push('');
149
+ if (notes && notes.length) {
150
+ parts.push('Team (slug: what they are on now):');
151
+ for (const entry of notes) parts.push(`- ${entry.name}: ${entry.note}`);
152
+ } else {
153
+ parts.push(`Team slugs: ${members.length ? members.join(', ') : '(none)'}`);
154
+ }
155
+ return parts.join('\n');
156
+ }
157
+
158
+ // Notes go in until the prompt would pass the ask limit; whoever does not
159
+ // fit is still a valid slug, named on the last line.
160
+ function fitTeamNotes(base, notes, members, maxBytes = MAX_ASK_PROMPT_BYTES - 1024) {
161
+ const kept = [];
162
+ for (const entry of notes) {
163
+ const next = buildLinkPrompt({ ...base, members, notes: [...kept, entry] });
164
+ if (Buffer.byteLength(next) > maxBytes) break;
165
+ kept.push(entry);
166
+ }
167
+ const rest = members.filter((name) => !kept.some((entry) => entry.name === name));
168
+ let prompt = buildLinkPrompt({ ...base, members, notes: kept });
169
+ if (kept.length && rest.length) prompt += `\nOther team slugs: ${rest.join(', ')}`;
170
+ return prompt;
171
+ }
172
+
173
+ // The same read-only invocation `engine ask` builds, run from the temp
174
+ // folder so the workspace's own notes and memory do not leak into an answer
175
+ // that may use only its inputs. Claude-family engines also lose their read
176
+ // tools for the same reason.
177
+ function linkInvocation(job, timeoutMs) {
178
+ const invocation = buildReadOnlyEngineInvocation(job.engine, job.prompt, job.model, { timeoutMs, effort: job.effort || '' });
179
+ const args = [...invocation.args];
180
+ const tools = args.indexOf('--tools');
181
+ if (tools >= 0 && tools + 1 < args.length) args[tools + 1] = '';
182
+ return { ...invocation, args, cwd: os.tmpdir() };
183
+ }
184
+
185
+ function executeLinkJob(job, { timeoutMs, signal } = {}) {
186
+ const invocation = linkInvocation(job, timeoutMs);
187
+ return runAskProcess(invocation, { cwd: invocation.cwd, timeoutMs, signal });
188
+ }
189
+
190
+ // The engine's text to one object: fences stripped, first "{" to last "}".
191
+ function extractJsonObject(text) {
192
+ const raw = String(text || '').replace(/```(?:json)?/gi, '').trim();
193
+ const start = raw.indexOf('{');
194
+ const end = raw.lastIndexOf('}');
195
+ if (start < 0 || end <= start) return null;
196
+ try {
197
+ const parsed = JSON.parse(raw.slice(start, end + 1));
198
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ // Em and en dashes become commas, so a rule the model missed never reaches
205
+ // the board.
206
+ function plain(text) {
207
+ return oneLine(String(text == null ? '' : text)
208
+ .replace(/\s*[\u2014\u2013]\s*/g, ', ')
209
+ .replace(/,\s*,/g, ','));
210
+ }
211
+
212
+ function wordCount(text) {
213
+ return plain(text).split(' ').filter(Boolean).length;
214
+ }
215
+
216
+ // Shape the answer into the contract. Returns { ok, value } or
217
+ // { ok: false, error }.
218
+ function normalizeLinkAnswer(parsed, members) {
219
+ if (!parsed) return { ok: false, error: 'the engine did not answer with a json object' };
220
+ if (parsed.line === null || parsed.line === undefined || !plain(parsed.line)) {
221
+ const reason = plain(parsed.reason) || 'nothing specific stood out between these two';
222
+ return { ok: true, value: { line: null, reason } };
223
+ }
224
+ const line = plain(parsed.line);
225
+ if (wordCount(line) > MAX_LINE_WORDS) {
226
+ return { ok: false, error: `the line has ${wordCount(line)} words, more than ${MAX_LINE_WORDS}` };
227
+ }
228
+ const experiment = parsed.experiment && typeof parsed.experiment === 'object' ? parsed.experiment : {};
229
+ const label = plain(experiment.label);
230
+ const scope = plain(experiment.scope);
231
+ const why = plain(parsed.why);
232
+ if (!why || !label || !scope) return { ok: false, error: 'the answer is missing why, experiment.label, or experiment.scope' };
233
+ const by = String(parsed.by == null ? '' : parsed.by).trim();
234
+ return {
235
+ ok: true,
236
+ value: {
237
+ line,
238
+ why,
239
+ experiment: { label, scope },
240
+ by: by && members.includes(by) ? by : null,
241
+ hunch: parsed.hunch === true || String(parsed.hunch).toLowerCase() === 'true',
242
+ },
243
+ };
244
+ }
245
+
246
+ function compactFailure(answer) {
247
+ if (!answer) return 'no answer';
248
+ if (answer.reason === 'timeout') return `timed out after ${Math.round((answer.duration_ms || 0) / 1000)}s`;
249
+ const detail = String(answer.stderr || '').trim().split(/\r?\n/).find(Boolean);
250
+ return detail || String(answer.reason || 'failed').replace(/_/g, ' ');
251
+ }
252
+
253
+ // One run-record line, job "ask", task "link", so `atris engine roster
254
+ // --runs` shows how long links take. Best effort.
255
+ function recordLinkRun(root, worker, answer, startedAt) {
256
+ try {
257
+ const { appendRosterRun, parseRunUsage } = require('../lib/roster-runs');
258
+ let outcome = 'landed';
259
+ if (answer && (answer.timed_out || answer.reason === 'timeout')) outcome = 'stalled';
260
+ else if (!answer || !answer.ok) outcome = 'failed';
261
+ appendRosterRun(root, {
262
+ at: startedAt,
263
+ job: 'ask',
264
+ engine: worker.engine,
265
+ model: worker.model,
266
+ ...(worker.effort ? { effort: worker.effort } : {}),
267
+ max_seconds: Math.round(LINK_TIMEOUT_MS / 1000),
268
+ seconds: Math.max(0, (Number(answer && answer.duration_ms) || 0) / 1000),
269
+ outcome,
270
+ ...(outcome === 'landed' ? {} : { detail: compactFailure(answer) }),
271
+ task: 'link',
272
+ source: 'link',
273
+ ...parseRunUsage({ stdout: answer && answer.stdout, stderr: answer && answer.stderr }),
274
+ });
275
+ } catch { /* best effort */ }
276
+ }
277
+
278
+ function printHuman(value, ran) {
279
+ if (value.line === null) {
280
+ console.log(`\nnothing specific: ${value.reason}\n`);
281
+ return;
282
+ }
283
+ console.log(`\n${value.line}${value.hunch ? ' (hunch)' : ''}`);
284
+ console.log(`\n ${value.why}`);
285
+ console.log(`\n make: ${value.experiment.label}`);
286
+ console.log(` ${value.experiment.scope}`);
287
+ console.log(` by: ${value.by || 'nobody on the team fits yet'}`);
288
+ console.log(`\n ${ran.engine}${ran.model ? ` (${ran.model})` : ''}, ${ran.seconds}s\n`);
289
+ }
290
+
291
+ async function linkCommand(args = [], deps = {}) {
292
+ const root = deps.root || process.cwd();
293
+ let parsed;
294
+ try {
295
+ parsed = parseLinkArgs(args);
296
+ } catch (error) {
297
+ console.error(`link: ${error.message}`);
298
+ return 2;
299
+ }
300
+ if (parsed.help) {
301
+ console.log(usage());
302
+ return 0;
303
+ }
304
+
305
+ const fail = (error, extra = {}) => {
306
+ if (parsed.json) console.log(JSON.stringify({ ok: false, error, ...extra }, null, 2));
307
+ else console.error(`link: ${error}`);
308
+ return 1;
309
+ };
310
+
311
+ let report;
312
+ try {
313
+ report = deps.rosterReport ? deps.rosterReport(root) : require('./engine').rosterReport(root);
314
+ } catch (error) {
315
+ return fail(`could not read the roster: ${error.message || error}`);
316
+ }
317
+ const worker = pickLinkWorker(report);
318
+ if (!worker) return fail('the roster has no build engine; run: atris engine roster');
319
+
320
+ const members = rosterMemberNames(report, root);
321
+ const now = deps.now ? deps.now() : Date.now();
322
+ const notes = parsed.team
323
+ ? members.map((name) => ({ name, note: teamNote(root, name, now) })).filter((entry) => entry.note)
324
+ : [];
325
+ const base = { things: parsed.things, context: parsed.context };
326
+ const prompt = notes.length
327
+ ? fitTeamNotes(base, notes, members)
328
+ : buildLinkPrompt({ ...base, members, notes: [] });
329
+
330
+ const startedAt = new Date().toISOString();
331
+ const job = { engine: worker.engine, model: worker.model, ...(worker.effort ? { effort: worker.effort } : {}), prompt, label: 'link' };
332
+ const [answer] = await runEngineAskJobs([job], {
333
+ root,
334
+ concurrency: 1,
335
+ timeoutMs: deps.timeoutMs || LINK_TIMEOUT_MS,
336
+ executeAskJob: deps.executeAskJob || executeLinkJob,
337
+ });
338
+ if (!deps.executeAskJob) recordLinkRun(root, worker, answer, startedAt);
339
+
340
+ const ran = {
341
+ job: worker.job,
342
+ engine: worker.engine,
343
+ model: worker.model || null,
344
+ seconds: Math.round((Number(answer && answer.duration_ms) || 0) / 100) / 10,
345
+ team_notes: parsed.team ? notes.length : null,
346
+ };
347
+ if (!answer || !answer.ok) return fail(`the engine did not answer: ${compactFailure(answer)}`, { ran });
348
+
349
+ const result = normalizeLinkAnswer(extractJsonObject(answer.stdout), members);
350
+ if (!result.ok) return fail(result.error, { ran, raw: String(answer.stdout || '').trim().slice(0, 2000) });
351
+
352
+ if (parsed.json) console.log(JSON.stringify({ ...result.value, ran }, null, 2));
353
+ else printHuman(result.value, ran);
354
+ return 0;
355
+ }
356
+
357
+ module.exports = {
358
+ SYSTEM_PROMPT,
359
+ linkCommand,
360
+ parseLinkArgs,
361
+ pickLinkWorker,
362
+ teamNote,
363
+ fitTeamNotes,
364
+ extractJsonObject,
365
+ normalizeLinkAnswer,
366
+ linkInvocation,
367
+ };
package/commands/loops.js CHANGED
@@ -276,7 +276,10 @@ function structuralProblems({ root, filePath, text, nowMs }) {
276
276
 
277
277
  const owner = text.match(/\*\*Owner:\*\*\s*`team\/([^`]+)`/)?.[1]?.trim();
278
278
  if (!owner) problems.push('no Owner field');
279
- else if (!fs.existsSync(path.join(root, 'team', owner))) problems.push(`owner team/${owner} does not exist`);
279
+ // Members live in atris/team/; older workspaces kept a root team/ folder.
280
+ else if (![path.join(root, 'atris', 'team', owner), path.join(root, 'team', owner)].some((dir) => fs.existsSync(dir))) {
281
+ problems.push(`owner team/${owner} does not exist`);
282
+ }
280
283
 
281
284
  const wiki = text.match(/\*\*Wiki:\*\*\s*\[[^\]]+\]\(([^)]+)\)/)?.[1]?.trim();
282
285
  if (!wiki) {