atris 3.79.0 → 3.80.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,1014 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const { memberRefusalReason } = require('./member-type');
5
+ const { readJson, writeJson } = require('./json-file');
6
+
7
+ function findWorkspaceBusinessId(startDir = process.cwd()) {
8
+ let dir = path.resolve(startDir);
9
+ while (true) {
10
+ const file = path.join(dir, '.atris', 'business.json');
11
+ if (fs.existsSync(file)) {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(file, 'utf8')).business_id || null;
14
+ } catch (error) {
15
+ console.warn(`Warning: could not parse ${file}: ${error.message}`);
16
+ return null;
17
+ }
18
+ }
19
+ const parent = path.dirname(dir);
20
+ if (parent === dir) return null;
21
+ dir = parent;
22
+ }
23
+ }
24
+
25
+ function todayLogName() {
26
+ const now = new Date();
27
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}.md`;
28
+ }
29
+
30
+ function ensureMemberLog(memberDir, { name, role, description, source = 'cli' } = {}) {
31
+ const logsDir = path.join(memberDir, 'logs');
32
+ fs.mkdirSync(logsDir, { recursive: true });
33
+ const logPath = path.join(logsDir, todayLogName());
34
+ if (fs.existsSync(logPath)) return logPath;
35
+ const stamp = new Date().toTimeString().slice(0, 5);
36
+ const content = [
37
+ `## ${stamp} · Member initialized`,
38
+ `- team: ${name || path.basename(memberDir)}`,
39
+ role ? `- role: ${role}` : '',
40
+ description ? `- mission: ${description}` : '',
41
+ `- source: ${source}`,
42
+ '- status: ready_for_room',
43
+ ].filter(Boolean).join('\n') + '\n';
44
+ fs.writeFileSync(logPath, content, 'utf8');
45
+ return logPath;
46
+ }
47
+
48
+ function appendMemberLifecycleLog(memberDir, name, action, detail = '') {
49
+ const logsDir = path.join(memberDir, 'logs');
50
+ fs.mkdirSync(logsDir, { recursive: true });
51
+ const logPath = path.join(logsDir, todayLogName());
52
+ const stamp = new Date().toTimeString().slice(0, 5);
53
+ const content = [
54
+ `## ${stamp} · Member ${action}`,
55
+ `- team: ${name || path.basename(memberDir)}`,
56
+ detail ? `- detail: ${detail}` : '',
57
+ `- status: ${action}`,
58
+ '',
59
+ ].filter(Boolean).join('\n');
60
+ fs.appendFileSync(logPath, content, 'utf8');
61
+ return logPath;
62
+ }
63
+
64
+ function archiveName(name) {
65
+ return `${name}-${todayLogName().replace(/\.md$/, '')}`;
66
+ }
67
+
68
+ function uniqueArchiveDir(archiveRoot, name) {
69
+ const base = archiveName(name);
70
+ let candidate = path.join(archiveRoot, base);
71
+ let i = 2;
72
+ while (fs.existsSync(candidate)) {
73
+ candidate = path.join(archiveRoot, `${base}-${i}`);
74
+ i += 1;
75
+ }
76
+ return candidate;
77
+ }
78
+
79
+ function parseDaysFlag(flags, fallback = 60) {
80
+ const joined = flags.join(' ');
81
+ const match = joined.match(/--days[=\s]+(\d+)/);
82
+ const days = match ? Number(match[1]) : fallback;
83
+ return Number.isFinite(days) && days >= 0 ? days : fallback;
84
+ }
85
+
86
+ function parseConfirmFlag(flags) {
87
+ const joined = flags.join(' ');
88
+ return joined.match(/--confirm[=\s]+["']?([^"']+)["']?/)?.[1] || '';
89
+ }
90
+
91
+ function readRepeatedFlag(args, name) {
92
+ const values = [];
93
+ const prefix = `${name}=`;
94
+ for (let i = 0; i < args.length; i += 1) {
95
+ const arg = String(args[i]);
96
+ if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) {
97
+ values.push(String(args[i + 1]).replace(/^["']|["']$/g, ''));
98
+ i += 1;
99
+ continue;
100
+ }
101
+ if (arg.startsWith(prefix)) values.push(arg.slice(prefix.length).replace(/^["']|["']$/g, ''));
102
+ }
103
+ return values.filter(Boolean);
104
+ }
105
+
106
+ function stripKnownFlags(args, valueNames, booleanNames = []) {
107
+ const out = [];
108
+ const valueSet = new Set(valueNames);
109
+ const booleanSet = new Set(['--full', '--verbose', ...booleanNames]);
110
+ for (let i = 0; i < args.length; i += 1) {
111
+ const arg = String(args[i]);
112
+ const key = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
113
+ if (booleanSet.has(key)) continue;
114
+ if (valueSet.has(key)) {
115
+ if (!arg.includes('=') && args[i + 1] && !String(args[i + 1]).startsWith('--')) i += 1;
116
+ continue;
117
+ }
118
+ out.push(args[i]);
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function stampIso() {
124
+ return new Date().toISOString();
125
+ }
126
+
127
+ function fileSafeStamp() {
128
+ return stampIso().replace(/[:.]/g, '-');
129
+ }
130
+
131
+ function slugHash(value) {
132
+ return crypto.createHash('sha1').update(String(value || '')).digest('hex').slice(0, 8);
133
+ }
134
+
135
+ function makeGoalId(title) {
136
+ return `goal-${todayLogName().replace(/\.md$/, '')}-${slugHash(title)}`;
137
+ }
138
+
139
+ function uniqueGoalId(state, baseId) {
140
+ const existingIds = new Set((state.goals || []).map((goal) => goal.id).filter(Boolean));
141
+ if (!existingIds.has(baseId)) return baseId;
142
+ let index = 2;
143
+ let id = `${baseId}-${index}`;
144
+ while (existingIds.has(id)) {
145
+ index += 1;
146
+ id = `${baseId}-${index}`;
147
+ }
148
+ return id;
149
+ }
150
+
151
+ function makeExperimentId(goalId, title) {
152
+ return `exp-${slugHash(`${goalId}:${title}:${Date.now()}`)}`;
153
+ }
154
+
155
+ function taskRef(task) {
156
+ return task?.display_id || task?.displayId || task?.legacy_ref || task?.legacyRef || task?.ref || task?.id || null;
157
+ }
158
+
159
+ function lowerCompact(value) {
160
+ return String(value || '').trim().toLowerCase();
161
+ }
162
+
163
+ function shellQuote(value) {
164
+ return `'${String(value || '').replace(/'/g, "'\\''")}'`;
165
+ }
166
+
167
+ const MEMBER_RUNTIME_ALIASES = Object.freeze({
168
+ 'problem-finder': 'signal-scout',
169
+ 'info-organizer': 'wiki-miner',
170
+ coordinator: 'supervisor',
171
+ 'task-planner': 'objective-generator',
172
+ improver: 'architect',
173
+ 'problem-solver': 'generalist',
174
+ });
175
+
176
+ function memberRuntimeKind(name) {
177
+ const key = lowerCompact(name);
178
+ return MEMBER_RUNTIME_ALIASES[key] || key;
179
+ }
180
+
181
+ function resolveMemberRuntime(name) {
182
+ const requestedName = String(name || '').trim();
183
+ const requestedPaths = memberPaths(requestedName);
184
+ const runtimeKind = memberRuntimeKind(requestedName);
185
+ const aliasOf = runtimeKind !== lowerCompact(requestedName) ? runtimeKind : null;
186
+ if (requestedName && fs.existsSync(requestedPaths.memberFile)) {
187
+ return {
188
+ requestedName,
189
+ storageName: requestedName,
190
+ runtimeKind,
191
+ aliasOf,
192
+ paths: requestedPaths,
193
+ };
194
+ }
195
+ if (aliasOf) {
196
+ const legacyPaths = memberPaths(runtimeKind);
197
+ if (fs.existsSync(legacyPaths.memberFile)) {
198
+ return {
199
+ requestedName,
200
+ storageName: runtimeKind,
201
+ runtimeKind,
202
+ aliasOf,
203
+ paths: legacyPaths,
204
+ };
205
+ }
206
+ }
207
+ return {
208
+ requestedName,
209
+ storageName: requestedName,
210
+ runtimeKind,
211
+ aliasOf,
212
+ paths: requestedPaths,
213
+ };
214
+ }
215
+
216
+ function memberPaths(name) {
217
+ const teamDir = path.join(process.cwd(), 'atris', 'team');
218
+ const memberDir = path.join(teamDir, name || '');
219
+ // Steering signals live in the workspace's single .atris/state store. Resolve
220
+ // the shared workspace root (spine -> git -> cwd) so a member run from a
221
+ // subdirectory reads steering from the real store instead of a nonexistent
222
+ // nested .atris, the same resolver the mission/task/usage/autopilot state
223
+ // uses. (teamDir markdown discovery is a separate cwd concern, filed.)
224
+ const workspaceRoot = require('./mission-root').resolveWorkspaceRoot(process.cwd());
225
+ return {
226
+ teamDir,
227
+ memberDir,
228
+ memberFile: path.join(memberDir, 'MEMBER.md'),
229
+ missionFile: path.join(memberDir, 'MISSION.md'),
230
+ goalsJson: path.join(memberDir, 'goals.json'),
231
+ goalsMd: path.join(memberDir, 'goals.md'),
232
+ steeringJsonl: path.join(workspaceRoot, '.atris', 'state', 'steering.jsonl'),
233
+ };
234
+ }
235
+
236
+ function missionFileMarkdown({ name, role, description } = {}) {
237
+ const title = role || name || 'Member';
238
+ const purpose = description || `Define why ${name || 'this member'} exists and how it chooses goals.`;
239
+ return [
240
+ '# Mission',
241
+ '',
242
+ '<!-- Human-authored purpose file. Keep this durable; runtime state belongs in .atris/state/*.jsonl and now.md. -->',
243
+ '',
244
+ '## North Star',
245
+ '',
246
+ purpose,
247
+ '',
248
+ '## How To Choose Goals',
249
+ '',
250
+ `- Use MEMBER.md to stay inside ${title}'s identity, authority, and tools.`,
251
+ '- Choose one useful bounded goal toward this mission.',
252
+ '- Verify the work, write the receipt, and update the log.',
253
+ '- Ask the human when vision, taste, risk, or uncertainty matters.',
254
+ '',
255
+ ].join('\n');
256
+ }
257
+
258
+ function ensureMissionFile(memberDir, { name, role, description } = {}) {
259
+ const missionPath = path.join(memberDir, 'MISSION.md');
260
+ if (!fs.existsSync(missionPath)) {
261
+ fs.writeFileSync(missionPath, missionFileMarkdown({ name, role, description }), 'utf8');
262
+ }
263
+ return missionPath;
264
+ }
265
+
266
+ function listExistingMemberSlugs() {
267
+ const teamDir = path.join(process.cwd(), 'atris', 'team');
268
+ try {
269
+ return fs.readdirSync(teamDir, { withFileTypes: true })
270
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('_'))
271
+ .map(entry => entry.name)
272
+ .filter(slug => fs.existsSync(path.join(teamDir, slug, 'MEMBER.md')))
273
+ .sort();
274
+ } catch {
275
+ return [];
276
+ }
277
+ }
278
+
279
+ function editDistance(a, b) {
280
+ const rows = a.length + 1;
281
+ const cols = b.length + 1;
282
+ const dist = Array.from({ length: rows }, (_, i) => [i, ...Array(cols - 1).fill(0)]);
283
+ for (let j = 0; j < cols; j++) dist[0][j] = j;
284
+ for (let i = 1; i < rows; i++) {
285
+ for (let j = 1; j < cols; j++) {
286
+ dist[i][j] = Math.min(
287
+ dist[i - 1][j] + 1,
288
+ dist[i][j - 1] + 1,
289
+ dist[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
290
+ );
291
+ }
292
+ }
293
+ return dist[rows - 1][cols - 1];
294
+ }
295
+
296
+ function nearestMemberSlug(name, slugs) {
297
+ const needle = String(name || '').toLowerCase();
298
+ let best = null;
299
+ let bestScore = Infinity;
300
+ for (const slug of slugs) {
301
+ const score = editDistance(needle, slug.toLowerCase());
302
+ if (score < bestScore) {
303
+ best = slug;
304
+ bestScore = score;
305
+ }
306
+ }
307
+ return bestScore <= Math.max(2, Math.floor(needle.length / 3)) ? best : null;
308
+ }
309
+
310
+ // A person is never run as an agent. Reads the member's own type and returns
311
+ // the plain refusal sentence, or '' when the member can run.
312
+ function memberRefusalFor(name, memberFile) {
313
+ try {
314
+ return memberRefusalReason(name, parseFrontmatter(fs.readFileSync(memberFile, 'utf8')) || {});
315
+ } catch {
316
+ return '';
317
+ }
318
+ }
319
+
320
+ // Same as requireMemberDir, then stops with a non-zero exit for a human or
321
+ // odd-typed member. Every path that runs a member as an agent starts here.
322
+ function requireRunnableMember(name) {
323
+ const paths = requireMemberDir(name);
324
+ const reason = memberRefusalFor(name, paths.memberFile);
325
+ if (reason) {
326
+ console.error(reason);
327
+ process.exit(1);
328
+ }
329
+ return paths;
330
+ }
331
+
332
+ function requireMemberDir(name) {
333
+ if (!name) {
334
+ console.error('Usage: atris member <goal|tick|review> <name> ...');
335
+ process.exit(1);
336
+ }
337
+ if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
338
+ console.error('Member name must be a local slug: letters, numbers, dots, underscores, or dashes.');
339
+ process.exit(1);
340
+ }
341
+ const resolved = resolveMemberRuntime(name);
342
+ const paths = resolved.paths;
343
+ if (!fs.existsSync(paths.memberFile)) {
344
+ const aliasHint = resolved.aliasOf ? ` or atris/team/${resolved.aliasOf}/MEMBER.md` : '';
345
+ console.error(`Member "${name}" not found at atris/team/${name}/MEMBER.md${aliasHint}`);
346
+ const team = listExistingMemberSlugs();
347
+ const nearest = nearestMemberSlug(name, team);
348
+ if (nearest) {
349
+ console.error(`Did you mean "${nearest}"?`);
350
+ } else if (team.length) {
351
+ console.error(`Team here: ${team.join(', ')}`);
352
+ } else {
353
+ console.error(`No team members exist here yet. Create one: atris member create ${name} --role="..."`);
354
+ }
355
+ process.exit(1);
356
+ }
357
+ return {
358
+ ...paths,
359
+ requestedName: resolved.requestedName,
360
+ storageName: resolved.storageName,
361
+ runtimeKind: resolved.runtimeKind,
362
+ aliasOf: resolved.aliasOf,
363
+ };
364
+ }
365
+
366
+ function emptyMemberGoals(name) {
367
+ return {
368
+ schema: 'atris.member_goals.v1',
369
+ member: name,
370
+ updated_at: stampIso(),
371
+ goals: [],
372
+ };
373
+ }
374
+
375
+ function loadMemberGoals(name, paths = memberPaths(name)) {
376
+ const parsed = readJson(paths.goalsJson, {}) || {};
377
+ return {
378
+ ...emptyMemberGoals(name),
379
+ ...parsed,
380
+ goals: Array.isArray(parsed.goals) ? parsed.goals : [],
381
+ };
382
+ }
383
+
384
+ function activeGoal(goalsState, goalId = '') {
385
+ if (goalId) return goalsState.goals.find((goal) => goal.id === goalId) || null;
386
+ for (let i = (goalsState.goals || []).length - 1; i >= 0; i -= 1) {
387
+ if (goalsState.goals[i]?.status === 'active') return goalsState.goals[i];
388
+ }
389
+ return goalsState.goals[0] || null;
390
+ }
391
+
392
+ function allExperiments(state) {
393
+ const out = [];
394
+ for (const goal of state.goals || []) {
395
+ for (const experiment of goal.experiments || []) out.push({ goal, experiment });
396
+ }
397
+ return out;
398
+ }
399
+
400
+ function findExperiment(state, experimentId) {
401
+ for (const item of allExperiments(state)) {
402
+ if (item.experiment.id === experimentId) return item;
403
+ }
404
+ return { goal: null, experiment: null };
405
+ }
406
+
407
+ function latestByTime(items, field = 'created_at') {
408
+ return items
409
+ .filter(Boolean)
410
+ .slice()
411
+ .sort((a, b) => String(b[field] || '').localeCompare(String(a[field] || '')))[0] || null;
412
+ }
413
+
414
+ function recentLogLines(memberDir, maxLines = 8) {
415
+ const logsDir = path.join(memberDir, 'logs');
416
+ if (!fs.existsSync(logsDir)) return [];
417
+ const logs = fs.readdirSync(logsDir)
418
+ .filter((name) => /^\d{4}-\d{2}-\d{2}\.md$/.test(name))
419
+ .sort();
420
+ const latest = logs[logs.length - 1];
421
+ if (!latest) return [];
422
+ return fs.readFileSync(path.join(logsDir, latest), 'utf8')
423
+ .split(/\r?\n/)
424
+ .filter((line) => line.trim())
425
+ .slice(-maxLines);
426
+ }
427
+
428
+ function readOptionalText(filePath) {
429
+ try {
430
+ return fs.readFileSync(filePath, 'utf8');
431
+ } catch {
432
+ return '';
433
+ }
434
+ }
435
+
436
+ function extractMarkdownSection(text, heading) {
437
+ const lines = String(text || '').split(/\r?\n/);
438
+ const target = String(heading || '').trim().toLowerCase();
439
+ const start = lines.findIndex((line) => {
440
+ const match = line.match(/^##\s+(.+?)\s*$/);
441
+ return match && match[1].trim().toLowerCase() === target;
442
+ });
443
+ if (start === -1) return '';
444
+ const out = [];
445
+ for (let i = start + 1; i < lines.length; i += 1) {
446
+ if (/^##\s+/.test(lines[i])) break;
447
+ out.push(lines[i]);
448
+ }
449
+ return out.join('\n').trim();
450
+ }
451
+
452
+ function firstUsefulLine(text) {
453
+ return String(text || '')
454
+ .split(/\r?\n/)
455
+ .map((line) => line.replace(/^[-*]\s+/, '').trim())
456
+ .find((line) => line && !line.startsWith('<!--')) || '';
457
+ }
458
+
459
+ function compactSentence(text, max = 120) {
460
+ const clean = String(text || '').replace(/\s+/g, ' ').trim();
461
+ if (clean.length <= max) return clean;
462
+ // Cut at a word boundary so titles never end mid-word ("rollb...").
463
+ const cut = clean.slice(0, Math.max(0, max - 1));
464
+ const lastSpace = cut.lastIndexOf(' ');
465
+ return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trim()}...`;
466
+ }
467
+
468
+ function activeRuntimeMissionFromNow(nowText) {
469
+ const heading = String(nowText || '').match(/^##\s+(.+?)\s*$/m)?.[1]?.trim() || '';
470
+ const id = String(nowText || '').match(/^- id:\s*(.+?)\s*$/m)?.[1]?.trim() || '';
471
+ const status = String(nowText || '').match(/^- status:\s*(.+?)\s*$/m)?.[1]?.trim() || '';
472
+ const next = String(nowText || '').match(/^- next:\s*(.+?)\s*$/m)?.[1]?.trim() || '';
473
+ return { heading, id, status, next };
474
+ }
475
+
476
+ function missionPurpose(paths) {
477
+ const missionText = readOptionalText(paths.missionFile);
478
+ const nowText = readOptionalText(path.join(paths.memberDir, 'now.md'));
479
+ const northStar = firstUsefulLine(extractMarkdownSection(missionText, 'North Star'));
480
+ const goalGuidance = extractMarkdownSection(missionText, 'How To Choose Goals');
481
+ const runtimeMission = activeRuntimeMissionFromNow(nowText);
482
+ const meaningful = Boolean(northStar) && !/define why .* exists/i.test(northStar);
483
+ return {
484
+ missionText,
485
+ nowText,
486
+ northStar,
487
+ goalGuidance,
488
+ runtimeMission,
489
+ meaningful,
490
+ };
491
+ }
492
+
493
+ const MEMBER_RUN_RUNNABLE_STATUSES = new Set(['planning', 'running', 'ready']);
494
+ const MISSION_TERMINAL_STATUSES = new Set(['complete', 'stopped', 'failed']);
495
+
496
+ function memberRunMissionMap(root = process.cwd()) {
497
+ try {
498
+ const missionMod = require('../commands/mission');
499
+ const map = new Map(missionMod.loadMissionMap(root));
500
+ for (const rolled of missionMod.listWorktreeRollupMissions(root)) {
501
+ if (!rolled?.id) continue;
502
+ const existing = map.get(rolled.id);
503
+ if (!existing || missionTimeMs(rolled) >= missionTimeMs(existing)) {
504
+ map.set(rolled.id, rolled);
505
+ }
506
+ }
507
+ return map;
508
+ } catch {
509
+ return new Map();
510
+ }
511
+ }
512
+
513
+ function memberRunRunnableMissionId(candidateId, missionMap) {
514
+ const id = String(candidateId || '').trim();
515
+ if (!id) return '';
516
+ const mission = missionMap.get(id);
517
+ // Unknown here can mean a worktree-held mission; let mission run decide.
518
+ if (!mission) return id;
519
+ return MEMBER_RUN_RUNNABLE_STATUSES.has(mission.status) ? id : '';
520
+ }
521
+
522
+ function memberRunOwnedActiveMissionId(name, missionMap) {
523
+ // Discovery fallback when the member's own pointers are stale: resume the
524
+ // member's newest moving mission. ready is excluded, it waits for review.
525
+ const owner = String(name || '').trim().toLowerCase();
526
+ const candidates = Array.from(missionMap.values())
527
+ .filter((mission) => String(mission?.owner || '').trim().toLowerCase() === owner)
528
+ .filter((mission) => mission.status === 'running' || mission.status === 'planning')
529
+ .filter((mission) => !/^decide and start the next useful mission after:/i.test(String(mission?.objective || '')))
530
+ .sort((a, b) => {
531
+ if (a.status !== b.status) return a.status === 'running' ? -1 : 1;
532
+ return String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || ''));
533
+ });
534
+ return candidates[0]?.id || '';
535
+ }
536
+
537
+ const MEMBER_LIVE_MISSION_STATUSES = new Set(['running', 'planning']);
538
+ const MEMBER_ACTIVE_ACTIVITY_MS = 24 * 60 * 60 * 1000;
539
+ const MEMBER_RECENT_ACTIVITY_MS = 72 * 60 * 60 * 1000;
540
+
541
+ function memberSlugFromRecord(member) {
542
+ if (member?.format === 'directory' && member.dir) return path.basename(member.dir);
543
+ if (member?.format === 'flat' && member.path) return path.basename(member.path, '.md');
544
+ return lowerCompact(member?.name || '');
545
+ }
546
+
547
+ function isFleetMember(member) {
548
+ if (!member || member.format !== 'directory') return false;
549
+ const slug = memberSlugFromRecord(member);
550
+ return Boolean(slug) && !new Set(['_archive', '_archived', '_template']).has(slug);
551
+ }
552
+
553
+ function missionTimeMs(mission) {
554
+ return Date.parse(mission?.last_tick_at || mission?.updated_at || mission?.created_at || '') || 0;
555
+ }
556
+
557
+ function memberOwnedMissions(name, missionMap = memberRunMissionMap()) {
558
+ const owner = lowerCompact(name);
559
+ return Array.from(missionMap.values())
560
+ .filter((mission) => lowerCompact(mission?.owner) === owner)
561
+ .sort((a, b) => missionTimeMs(b) - missionTimeMs(a));
562
+ }
563
+
564
+ function memberLatestMission(name, missionMap = memberRunMissionMap()) {
565
+ return memberOwnedMissions(name, missionMap)[0] || null;
566
+ }
567
+
568
+ function memberLiveMission(name, missionMap = memberRunMissionMap()) {
569
+ return memberOwnedMissions(name, missionMap)
570
+ .find((mission) => {
571
+ if (!MEMBER_LIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase())) return false;
572
+ // A closed window can leave a running row behind. Only a known dead
573
+ // process proves it is free; missing process records still hold it.
574
+ try {
575
+ const root = mission.worktree_root || mission.worktree?.path || process.cwd();
576
+ const health = require('../commands/mission').missionDriverHealth(mission, root);
577
+ return !health.pid || health.alive;
578
+ } catch { return true; }
579
+ }) || null;
580
+ }
581
+
582
+ function missionShortName(mission, max = 36) {
583
+ return compactSentence(mission?.slug || mission?.title || mission?.objective || mission?.id || '', max) || '-';
584
+ }
585
+
586
+ function ageLabelFromMs(ms, nowMs = Date.now()) {
587
+ if (!ms) return '-';
588
+ const seconds = Math.max(0, Math.floor((nowMs - ms) / 1000));
589
+ if (seconds < 60) return 'now';
590
+ const minutes = Math.floor(seconds / 60);
591
+ if (minutes < 60) return `${minutes}m`;
592
+ const hours = Math.floor(minutes / 60);
593
+ if (hours < 48) return `${hours}h`;
594
+ return `${Math.floor(hours / 24)}d`;
595
+ }
596
+
597
+ function timestampMs(value) {
598
+ const ms = Date.parse(value || '');
599
+ return Number.isFinite(ms) ? ms : 0;
600
+ }
601
+
602
+ function newestMtimeRecursive(root) {
603
+ if (!fs.existsSync(root)) return 0;
604
+ let newest = 0;
605
+ const stack = [root];
606
+ while (stack.length) {
607
+ const current = stack.pop();
608
+ let stat = null;
609
+ try {
610
+ stat = fs.statSync(current);
611
+ } catch {
612
+ continue;
613
+ }
614
+ newest = Math.max(newest, stat.mtimeMs || 0);
615
+ if (!stat.isDirectory()) continue;
616
+ let entries = [];
617
+ try {
618
+ entries = fs.readdirSync(current);
619
+ } catch {
620
+ continue;
621
+ }
622
+ for (const entry of entries) stack.push(path.join(current, entry));
623
+ }
624
+ return newest;
625
+ }
626
+
627
+ function newestRunActivityMtime(name, root = process.cwd()) {
628
+ const runsDir = path.join(root, 'atris', 'runs');
629
+ if (!fs.existsSync(runsDir)) return 0;
630
+ const needle = lowerCompact(name);
631
+ let newest = 0;
632
+ for (const entry of fs.readdirSync(runsDir)) {
633
+ if (!lowerCompact(entry).includes(needle)) continue;
634
+ try {
635
+ newest = Math.max(newest, fs.statSync(path.join(runsDir, entry)).mtimeMs || 0);
636
+ } catch { /* ignore unreadable activity entries */ }
637
+ }
638
+ return newest;
639
+ }
640
+
641
+ function memberLastActivity(name, paths = memberPaths(name), root = process.cwd()) {
642
+ const logsMs = newestMtimeRecursive(path.join(paths.memberDir, 'logs'));
643
+ const runsMs = newestRunActivityMtime(name, root);
644
+ const ms = Math.max(logsMs, runsMs);
645
+ return {
646
+ at_ms: ms,
647
+ age: ageLabelFromMs(ms),
648
+ };
649
+ }
650
+
651
+ function readJsonlRowsIfExists(filePath, maxRows = 80) {
652
+ const tailBytes = 256 * 1024;
653
+ let fd;
654
+ let text = '';
655
+ let truncated = false;
656
+ try {
657
+ fd = fs.openSync(filePath, 'r');
658
+ const stat = fs.fstatSync(fd);
659
+ const bytesToRead = Math.min(stat.size, tailBytes);
660
+ const start = Math.max(0, stat.size - bytesToRead);
661
+ const buffer = Buffer.alloc(bytesToRead);
662
+ let bytesRead = 0;
663
+ while (bytesRead < bytesToRead) {
664
+ const count = fs.readSync(
665
+ fd,
666
+ buffer,
667
+ bytesRead,
668
+ bytesToRead - bytesRead,
669
+ start + bytesRead
670
+ );
671
+ if (count === 0) break;
672
+ bytesRead += count;
673
+ }
674
+ text = buffer.toString('utf8', 0, bytesRead);
675
+ truncated = start > 0;
676
+ } catch {
677
+ return [];
678
+ } finally {
679
+ if (fd !== undefined) {
680
+ try { fs.closeSync(fd); } catch {}
681
+ }
682
+ }
683
+ const rows = [];
684
+ const lines = text
685
+ .split(/\r?\n/)
686
+ .slice(truncated ? 1 : 0)
687
+ .filter((line) => line.trim())
688
+ .slice(-Math.max(1, maxRows));
689
+ for (const line of lines) {
690
+ try {
691
+ rows.push(JSON.parse(line));
692
+ } catch {
693
+ // Bad rows should not block discovery from valid rows in the same stream.
694
+ }
695
+ }
696
+ return rows;
697
+ }
698
+
699
+ function writeMemberReceipt(prefix, payload, { root = process.cwd(), receiptPath = null } = {}) {
700
+ const runsDir = path.join(root, 'atris', 'runs');
701
+ fs.mkdirSync(runsDir, { recursive: true });
702
+ const finalReceiptPath = receiptPath || path.join(runsDir, `${prefix}-${fileSafeStamp()}.json`);
703
+ writeJson(finalReceiptPath, payload);
704
+ return finalReceiptPath;
705
+ }
706
+
707
+ function writeAutonomousDiscoveryReceipt(name, candidate, taskResult) {
708
+ const payload = {
709
+ schema: 'atris.signal_scout_autonomous_discovery.v1',
710
+ created_at: stampIso(),
711
+ member: name,
712
+ source: candidate.source,
713
+ action: taskResult.existing ? 'existing_task_found' : 'task_created',
714
+ task_ref: taskResult.task_ref || null,
715
+ task_id: taskResult.task_id || taskResult.task?.id || null,
716
+ task_title: candidate.task_title || candidate.objective_title || candidate.title || null,
717
+ error_pattern: candidate.error_pattern || null,
718
+ error_count: candidate.error_count || null,
719
+ evidence_paths: candidate.evidence_paths || [],
720
+ command: taskResult.command || candidate.next_command || null,
721
+ ok: taskResult.ok === true,
722
+ error: taskResult.error || null,
723
+ };
724
+ const receiptPath = writeMemberReceipt('signal-scout-autonomous-discovery', payload);
725
+ const fields = {
726
+ team: name,
727
+ decision: 'auto-discovery',
728
+ pattern: payload.error_pattern,
729
+ count: payload.error_count,
730
+ task: payload.task_ref,
731
+ receipt: path.relative(process.cwd(), receiptPath),
732
+ };
733
+ const projectLogPath = appendProjectLog('Signal Scout autonomous problem discovery', fields);
734
+ return {
735
+ receipt_path: receiptPath,
736
+ project_log_path: projectLogPath,
737
+ payload,
738
+ };
739
+ }
740
+
741
+ function writeWakeReceipt(name, payload) {
742
+ return writeMemberReceipt(`member-wake-${name}`, payload);
743
+ }
744
+
745
+ function writeMemberLoopReceipt(name, payload) {
746
+ return writeMemberReceipt(`member-loop-${name}`, payload);
747
+ }
748
+
749
+ function writeAutoImproverReceipt(name, payload, receiptPath = null) {
750
+ const finalReceiptPath = writeMemberReceipt('auto-improver-dogfood', payload, { receiptPath });
751
+ const latestPath = path.join(process.cwd(), '.atris', 'state', 'auto-improver-dogfood-latest.json');
752
+ writeJson(latestPath, { ...payload, receipt_path: finalReceiptPath });
753
+ return { receiptPath: finalReceiptPath, latestPath };
754
+ }
755
+
756
+ function isPidAlive(pid) {
757
+ const numeric = Number(pid);
758
+ if (!Number.isFinite(numeric) || numeric <= 0) return false;
759
+ try {
760
+ process.kill(numeric, 0);
761
+ return true;
762
+ } catch (error) {
763
+ return error?.code === 'EPERM';
764
+ }
765
+ }
766
+
767
+ function safeReadText(filePath, maxBytes = 250000) {
768
+ try {
769
+ const stat = fs.statSync(filePath);
770
+ if (!stat.isFile() || stat.size > maxBytes) return '';
771
+ return fs.readFileSync(filePath, 'utf8');
772
+ } catch {
773
+ return '';
774
+ }
775
+ }
776
+
777
+ function repoRelative(root, filePath) {
778
+ return path.relative(root, filePath).replace(/\\/g, '/');
779
+ }
780
+
781
+ function dateFromLogPath(relativePath) {
782
+ const match = String(relativePath || '').match(/\b(\d{4}-\d{2}-\d{2})\b/);
783
+ return match ? match[1] : null;
784
+ }
785
+
786
+ // Mission driver logs, goals files, and the wiki log have no date in their
787
+ // name; without one they skipped the 7-day window and months-old lines kept
788
+ // counting as this week's pain. The last write date stands in for them.
789
+ function logDateForScan(relativePath, filePath) {
790
+ const named = dateFromLogPath(relativePath);
791
+ if (named) return named;
792
+ try {
793
+ // Local calendar day, like the dated journal names beside it;
794
+ // toISOString is UTC and calls an evening write tomorrow's.
795
+ const mtime = fs.statSync(filePath).mtime;
796
+ return `${mtime.getFullYear()}-${String(mtime.getMonth() + 1).padStart(2, '0')}-${String(mtime.getDate()).padStart(2, '0')}`;
797
+ } catch {
798
+ return null;
799
+ }
800
+ }
801
+
802
+ function compareLogEvidenceRecentFirst(a, b) {
803
+ const dateA = a?.date || '';
804
+ const dateB = b?.date || '';
805
+ if (dateA !== dateB) return dateB.localeCompare(dateA);
806
+ const pathCompare = String(a?.path || '').localeCompare(String(b?.path || ''));
807
+ if (pathCompare !== 0) return pathCompare;
808
+ return Number(a?.line || 0) - Number(b?.line || 0);
809
+ }
810
+
811
+ function compareRepeatedFailureSignals(a, b) {
812
+ const countCompare = Number(b?.count || 0) - Number(a?.count || 0);
813
+ if (countCompare !== 0) return countCompare;
814
+ const evidenceCompare = compareLogEvidenceRecentFirst(a?.evidence?.[0], b?.evidence?.[0]);
815
+ if (evidenceCompare !== 0) return evidenceCompare;
816
+ return String(a?.pattern || '').localeCompare(String(b?.pattern || ''));
817
+ }
818
+
819
+ function listFilesBounded(rootDir, { maxFiles = 220, extensions = ['.md', '.txt', '.json', '.jsonl'] } = {}) {
820
+ const files = [];
821
+ const stack = [rootDir];
822
+ const skipDirs = new Set(['.git', 'node_modules', '.next', 'dist', 'build', '__pycache__']);
823
+ while (stack.length && files.length < maxFiles) {
824
+ const current = stack.pop();
825
+ let entries = [];
826
+ try {
827
+ entries = fs.readdirSync(current, { withFileTypes: true });
828
+ } catch {
829
+ continue;
830
+ }
831
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
832
+ if (files.length >= maxFiles) break;
833
+ const fullPath = path.join(current, entry.name);
834
+ if (entry.isDirectory()) {
835
+ if (!skipDirs.has(entry.name) && !entry.name.startsWith('.')) stack.push(fullPath);
836
+ continue;
837
+ }
838
+ if (!entry.isFile()) continue;
839
+ if (extensions.includes(path.extname(entry.name))) files.push(fullPath);
840
+ }
841
+ }
842
+ return files;
843
+ }
844
+
845
+ function appendMemberGoalLog(memberDir, name, title, fields = {}) {
846
+ const logsDir = path.join(memberDir, 'logs');
847
+ fs.mkdirSync(logsDir, { recursive: true });
848
+ const logPath = path.join(logsDir, todayLogName());
849
+ const stamp = new Date().toTimeString().slice(0, 5);
850
+ const rows = [
851
+ `## ${stamp} · ${title}`,
852
+ `- team: ${name}`,
853
+ ...Object.entries(fields)
854
+ .filter(([, value]) => value !== undefined && value !== null)
855
+ .map(([key, value]) => [key, String(value).replace(/\n/g, ' ').trim()])
856
+ .filter(([, text]) => text !== '')
857
+ .map(([key, text]) => `- ${key}: ${text}`),
858
+ '',
859
+ ];
860
+ fs.appendFileSync(logPath, rows.join('\n'), 'utf8');
861
+ return logPath;
862
+ }
863
+
864
+ function appendProjectLog(title, fields = {}) {
865
+ const today = todayLogName();
866
+ const year = today.slice(0, 4);
867
+ const logsDir = path.join(process.cwd(), 'atris', 'logs', year);
868
+ fs.mkdirSync(logsDir, { recursive: true });
869
+ const logPath = path.join(logsDir, today);
870
+ const stamp = new Date().toTimeString().slice(0, 5);
871
+ const rows = [
872
+ `## ${stamp} · ${title}`,
873
+ ...Object.entries(fields)
874
+ .filter(([, value]) => value !== undefined && value !== null)
875
+ .map(([key, value]) => [key, String(value).replace(/\n/g, ' ').trim()])
876
+ .filter(([, text]) => text !== '')
877
+ .map(([key, text]) => `- ${key}: ${text}`),
878
+ '',
879
+ ];
880
+ fs.appendFileSync(logPath, rows.join('\n'), 'utf8');
881
+ return logPath;
882
+ }
883
+
884
+ // --- YAML Frontmatter Parser (shared with skill.js) ---
885
+
886
+ function parseFrontmatter(content) {
887
+ // \r?\n so a CRLF-line-ending MEMBER.md (Windows-edited) still parses; otherwise
888
+ // the delimiter never matched (and trailing \r broke the per-line key matchers),
889
+ // silently dropping the member's entire frontmatter: role, skills, permissions, tools.
890
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
891
+ if (!match) return null;
892
+
893
+ const yaml = match[1];
894
+ const result = {};
895
+ let currentKey = null;
896
+
897
+ for (const line of yaml.split(/\r?\n/)) {
898
+ const listMatch = line.match(/^\s+-\s+(.+)$/);
899
+ if (listMatch && currentKey) {
900
+ if (!Array.isArray(result[currentKey])) result[currentKey] = [];
901
+ result[currentKey].push(listMatch[1].trim());
902
+ continue;
903
+ }
904
+
905
+ const nestedMatch = line.match(/^\s+([a-z_-]+):\s*(.*)$/);
906
+ if (nestedMatch && currentKey && typeof result[currentKey] === 'object' && !Array.isArray(result[currentKey])) {
907
+ const val = nestedMatch[2].trim();
908
+ result[currentKey][nestedMatch[1]] = val === 'true' ? true : val === 'false' ? false : val || true;
909
+ continue;
910
+ }
911
+
912
+ const kvMatch = line.match(/^([a-z_-]+):\s*(.*)$/);
913
+ if (kvMatch) {
914
+ currentKey = kvMatch[1];
915
+ const val = kvMatch[2].trim();
916
+ if (val === '') {
917
+ result[currentKey] = {};
918
+ } else if (val.startsWith('[') && val.endsWith(']')) {
919
+ result[currentKey] = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
920
+ } else {
921
+ // Unwrap only a matching outer pair: `verify: node -e "process.exit(0)"`
922
+ // must keep its closing quote or the check is a shell syntax error.
923
+ const quoted = val.match(/^(["'])([\s\S]*)\1$/);
924
+ result[currentKey] = quoted ? quoted[2] : val;
925
+ }
926
+ }
927
+ }
928
+
929
+ return result;
930
+ }
931
+
932
+ module.exports = {
933
+ findWorkspaceBusinessId,
934
+ todayLogName,
935
+ ensureMemberLog,
936
+ appendMemberLifecycleLog,
937
+ archiveName,
938
+ uniqueArchiveDir,
939
+ parseDaysFlag,
940
+ parseConfirmFlag,
941
+ readRepeatedFlag,
942
+ stripKnownFlags,
943
+ stampIso,
944
+ fileSafeStamp,
945
+ slugHash,
946
+ makeGoalId,
947
+ uniqueGoalId,
948
+ makeExperimentId,
949
+ taskRef,
950
+ lowerCompact,
951
+ shellQuote,
952
+ MEMBER_RUNTIME_ALIASES,
953
+ memberRuntimeKind,
954
+ resolveMemberRuntime,
955
+ memberPaths,
956
+ missionFileMarkdown,
957
+ ensureMissionFile,
958
+ listExistingMemberSlugs,
959
+ editDistance,
960
+ nearestMemberSlug,
961
+ memberRefusalFor,
962
+ requireRunnableMember,
963
+ requireMemberDir,
964
+ emptyMemberGoals,
965
+ loadMemberGoals,
966
+ activeGoal,
967
+ allExperiments,
968
+ findExperiment,
969
+ latestByTime,
970
+ recentLogLines,
971
+ readOptionalText,
972
+ extractMarkdownSection,
973
+ firstUsefulLine,
974
+ compactSentence,
975
+ activeRuntimeMissionFromNow,
976
+ missionPurpose,
977
+ MEMBER_RUN_RUNNABLE_STATUSES,
978
+ MISSION_TERMINAL_STATUSES,
979
+ memberRunMissionMap,
980
+ memberRunRunnableMissionId,
981
+ memberRunOwnedActiveMissionId,
982
+ MEMBER_LIVE_MISSION_STATUSES,
983
+ MEMBER_ACTIVE_ACTIVITY_MS,
984
+ MEMBER_RECENT_ACTIVITY_MS,
985
+ memberSlugFromRecord,
986
+ isFleetMember,
987
+ missionTimeMs,
988
+ memberOwnedMissions,
989
+ memberLatestMission,
990
+ memberLiveMission,
991
+ missionShortName,
992
+ ageLabelFromMs,
993
+ timestampMs,
994
+ newestMtimeRecursive,
995
+ newestRunActivityMtime,
996
+ memberLastActivity,
997
+ readJsonlRowsIfExists,
998
+ writeMemberReceipt,
999
+ writeAutonomousDiscoveryReceipt,
1000
+ writeWakeReceipt,
1001
+ writeMemberLoopReceipt,
1002
+ writeAutoImproverReceipt,
1003
+ isPidAlive,
1004
+ safeReadText,
1005
+ repoRelative,
1006
+ dateFromLogPath,
1007
+ logDateForScan,
1008
+ compareLogEvidenceRecentFirst,
1009
+ compareRepeatedFailureSignals,
1010
+ listFilesBounded,
1011
+ appendMemberGoalLog,
1012
+ appendProjectLog,
1013
+ parseFrontmatter,
1014
+ };