atris 3.68.5 → 3.68.7

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/bin/atris.js CHANGED
@@ -630,6 +630,7 @@ function showHelpAll() {
630
630
  console.log(' rainmaker - Relationship manager dashboard (atrisos-backend/scripts/rainmaker.py)');
631
631
  console.log(' meet - onboard a stranger in one sitting and print their /book link');
632
632
  console.log(' avail - Booking availability (/book/{username} weekly windows)');
633
+ console.log(' scoreboard - Today\'s github contributions vs your best day, streak, repo commits (--watch, --json)');
633
634
  console.log(' brain - Compile MAP/TODO/wiki/state into a loadable agent brain');
634
635
  console.log(' lesson - atris lesson add <slug> <pass|fail> "<text>" (mine/sweep/resolve/revert/ledger; --detector validates the falsifier at write time)');
635
636
  console.log(' taste - Record the operator\'s keep, kill, and more creative verdicts');
@@ -2189,6 +2190,10 @@ if (command === 'guide') {
2189
2190
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
2190
2191
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2191
2192
  }
2193
+ } else if (command === 'scoreboard') {
2194
+ Promise.resolve(require('../commands/scoreboard').scoreboardCommand(process.argv.slice(3)))
2195
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2196
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2192
2197
  } else if (command === 'meet') {
2193
2198
  Promise.resolve(require('../commands/meet').meetCommand(process.argv.slice(3)))
2194
2199
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
@@ -1300,6 +1300,13 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1300
1300
  if (nodeModulesLinked) console.log(`linked node_modules from ${nodeModulesSource}`);
1301
1301
  }
1302
1302
 
1303
+ // An ignited run works for minutes; say where to watch it before it starts.
1304
+ if (ignite && !asJson && !quiet && !hasFlag(args, '--quiet')) {
1305
+ const progressDir = process.env.ATRIS_PROGRESS_DIR || '~/.atris/progress';
1306
+ console.log(`watch: tail -f ${progressDir}/${String(name).replace(/[^A-Za-z0-9._-]/g, '_')}.log`);
1307
+ console.log('board: atris member board --watch');
1308
+ }
1309
+
1303
1310
  const cwd = mission.worktree?.path || process.cwd();
1304
1311
  const verifier = readFlag(args, '--verify', '') || mission.verifier || requestedVerifier;
1305
1312
  if (verifier && verifier !== mission.verifier) {
@@ -9789,6 +9796,17 @@ const IDEA_ARCHETYPE_RULES = [
9789
9796
  },
9790
9797
  ];
9791
9798
 
9799
+ // Dispatch defaults to the core team. Other members (experiments, one-off
9800
+ // workers) win only when the idea names them or out-scores the core by 2x.
9801
+ const CORE_DISPATCH_MEMBERS = new Set([
9802
+ 'executor', 'architect', 'auto-improver', 'navigator', 'validator', 'customer-lead', 'researcher',
9803
+ ]);
9804
+
9805
+ function ideaNamesMember(text, slug) {
9806
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '[-\\s]+');
9807
+ return new RegExp(`(^|[^a-z0-9-])${escaped}($|[^a-z0-9-])`, 'i').test(text);
9808
+ }
9809
+
9792
9810
  function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '') {
9793
9811
  const teamDir = path.join(root, 'atris', 'team');
9794
9812
  const members = findAllMembers(teamDir).filter(isFleetMember);
@@ -9841,6 +9859,13 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
9841
9859
  if (slug === 'executor') score += 3;
9842
9860
  if (slug === 'auto-improver') score += 2;
9843
9861
 
9862
+ if (ideaNamesMember(text, slug)) {
9863
+ score += 40;
9864
+ matchedReason = 'named in the idea';
9865
+ } else if (!CORE_DISPATCH_MEMBERS.has(slug)) {
9866
+ score = Math.floor(score / 2);
9867
+ }
9868
+
9844
9869
  if (!matchedReason) {
9845
9870
  matchedReason = record.description
9846
9871
  ? compactSentence(record.description, 90)
@@ -9937,6 +9962,7 @@ function scrubFeedJargon(text) {
9937
9962
  return String(text || '')
9938
9963
  .replace(/\bmission-\d{4}-\d{2}-\d{2}-[a-z0-9-]+\b/gi, '')
9939
9964
  .replace(/\b[0-9a-f]{7,40}\b/g, '')
9965
+ .replace(/\(\s*(commit\s*)?\)/gi, '')
9940
9966
  .replace(/\s+/g, ' ')
9941
9967
  .trim();
9942
9968
  }
@@ -9966,68 +9992,65 @@ function buildMemberImpactChain({ summary, verified, goalWhy, nextPush, skills =
9966
9992
  };
9967
9993
  }
9968
9994
 
9995
+ // Rows that only look like work: placeholder goals, wake and blocked
9996
+ // notices, empty "logged update" rows, and raw commands.
9997
+ function isFeedFillerText(text) {
9998
+ const t = String(text || '').trim();
9999
+ if (!t) return true;
10000
+ if (/^serve the team as\b/i.test(t)) return true;
10001
+ if (/^member (wake|goal-from-mission|goal created|initialized)\b/i.test(t)) return true;
10002
+ if (/^\[?logged update\]?$/i.test(t)) return true;
10003
+ if (/^(next[_ ]push|verdict):/i.test(t)) return true;
10004
+ if (/^[`'"]?atris\s/i.test(t) || /\batris\s+[a-z-]+\s+[a-z-]+\s+--[a-z]/i.test(t)) return true;
10005
+ return false;
10006
+ }
10007
+
10008
+ // How a row was checked, in words a reader can follow without the command.
10009
+ function plainFeedCheckLabel(state, verifierCommand = '') {
10010
+ if (state === 'failed') return 'check failed, needs another pass';
10011
+ if (state !== 'passed') return 'not checked yet';
10012
+ const cmd = String(verifierCommand || '');
10013
+ if (/\b(npm|pnpm|yarn)\s+(run\s+)?test\b|--test\b|\bpytest\b|\bjest\b|\bvitest\b/i.test(cmd)) return 'checked: tests passed';
10014
+ if (/git\s+diff\s+--check/i.test(cmd)) return 'checked: the change has no whitespace errors';
10015
+ if (/\b(lint|eslint|tsc)\b/i.test(cmd)) return 'checked: lint passed';
10016
+ return 'checked: its check passed';
10017
+ }
10018
+
10019
+ function feedEntryTimeMs(file, header, fallbackMs) {
10020
+ const day = String(file).match(/^(\d{4})-(\d{2})-(\d{2})\.md$/);
10021
+ const clock = String(header).match(/^(\d{2}):(\d{2})\b/);
10022
+ if (!day) return fallbackMs;
10023
+ const ms = new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]), clock ? Number(clock[1]) : 23, clock ? Number(clock[2]) : 59).getTime();
10024
+ return Number.isFinite(ms) ? Math.min(ms, fallbackMs || ms) : fallbackMs;
10025
+ }
10026
+
10027
+ // The feed reads member log entries that say what changed. Mission records
10028
+ // only hold the assignment, so they are used to name how the work was checked.
9969
10029
  function collectMemberWorkFeed(root = process.cwd(), options = {}) {
9970
10030
  const teamDir = path.join(root, 'atris', 'team');
9971
10031
  const members = findAllMembers(teamDir).filter(isFleetMember);
9972
- const roleByMember = new Map(members.map((m) => [memberSlugFromRecord(m), m.role || 'Team Member']));
9973
- const metaByMember = new Map();
9974
- for (const m of members) {
9975
- const slug = memberSlugFromRecord(m);
9976
- const mDir = path.join(teamDir, slug);
9977
- const skills = listMemberUnlockedSkills(mDir, m.skills);
9978
- let goalWhy = null;
9979
- let goalTitle = null;
9980
- try {
9981
- const gp = memberGoalPlaneStatus(slug, { memberDir: mDir, goalsFile: path.join(mDir, 'goals.json') });
9982
- goalWhy = gp?.goal?.why || null;
9983
- goalTitle = gp?.goal?.title || null;
9984
- } catch {}
9985
- metaByMember.set(slug, { skills, goalWhy, goalTitle });
9986
- }
9987
10032
  const filterMember = String(options.member || '').trim().toLowerCase();
9988
10033
  const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0 ? Number(options.limit) : 15;
9989
10034
 
9990
- const items = [];
9991
- const missionMap = memberRunMissionMap(root);
9992
- for (const mission of missionMap.values()) {
9993
- const owner = String(mission?.owner || '').trim();
9994
- if (!owner) continue;
9995
- if (filterMember && owner.toLowerCase() !== filterMember) continue;
9996
- const atIso = mission.last_tick_at || mission.updated_at || mission.created_at || null;
9997
- const atMs = timestampMs(atIso);
9998
- const summaryText = scrubFeedJargon(mission.last_summary || mission.objective || '');
9999
- if (!summaryText) continue;
10000
- const cleanSummary = compactSentence(summaryText, 140);
10001
- const verified = Boolean(mission?.verifier_result?.passed);
10002
- const meta = metaByMember.get(owner) || { skills: [], goalWhy: null, goalTitle: null };
10003
- const nextPush = scrubFeedJargon(mission.last_next_push || meta.goalTitle || '');
10004
- const impactChain = buildMemberImpactChain({
10005
- summary: cleanSummary,
10006
- verified,
10007
- goalWhy: meta.goalWhy,
10008
- nextPush,
10009
- skills: meta.skills,
10010
- });
10011
- items.push({
10012
- member: owner,
10013
- role: roleByMember.get(owner) || 'Team Member',
10014
- at: atIso,
10015
- at_ms: atMs,
10016
- age: ageLabelFromMs(atMs),
10017
- summary: cleanSummary,
10018
- status: plainFeedStatusLabel(mission),
10019
- verified,
10020
- next_push: impactChain.compounding,
10021
- impact_chain: impactChain,
10022
- });
10035
+ const verifierByObjective = new Map();
10036
+ for (const mission of memberRunMissionMap(root).values()) {
10037
+ const objective = String(mission?.objective || '').trim();
10038
+ if (objective && mission?.verifier) verifierByObjective.set(`${mission.owner}\u0000${objective}`, String(mission.verifier));
10023
10039
  }
10024
10040
 
10041
+ const items = [];
10025
10042
  for (const record of members) {
10026
10043
  const slug = memberSlugFromRecord(record);
10027
10044
  if (filterMember && slug.toLowerCase() !== filterMember) continue;
10028
10045
  const logsDir = path.join(teamDir, slug, 'logs');
10029
10046
  if (!fs.existsSync(logsDir)) continue;
10030
- const logFiles = fs.readdirSync(logsDir).filter((f) => f.endsWith('.md')).sort().slice(-2);
10047
+ const mDir = path.join(teamDir, slug);
10048
+ let goalWhy = null;
10049
+ try {
10050
+ goalWhy = memberGoalPlaneStatus(slug, { memberDir: mDir, goalsFile: path.join(mDir, 'goals.json') })?.goal?.why || null;
10051
+ } catch {}
10052
+ const skills = listMemberUnlockedSkills(mDir, record.skills);
10053
+ const logFiles = fs.readdirSync(logsDir).filter((f) => f.endsWith('.md')).sort().slice(-7);
10031
10054
  for (const file of logFiles) {
10032
10055
  const fullPath = path.join(logsDir, file);
10033
10056
  let raw = '';
@@ -10038,42 +10061,45 @@ function collectMemberWorkFeed(root = process.cwd(), options = {}) {
10038
10061
  } catch {
10039
10062
  continue;
10040
10063
  }
10041
- const sections = raw.split(/^##\s+/m).slice(1);
10042
- for (const section of sections.slice(-3)) {
10064
+ for (const section of raw.split(/^##\s+/m).slice(1)) {
10043
10065
  const lines = section.trim().split(/\r?\n/);
10044
10066
  const header = lines[0] || '';
10045
- const summaryLine = lines.find((l) => /^-\s*(summary|mission|title):/i.test(l));
10046
- const verifierLine = lines.find((l) => /^-\s*verifier:/i.test(l));
10047
- const nextPushLine = lines.find((l) => /^-\s*(next_push|next push|next):/i.test(l));
10048
- const rawSummary = summaryLine
10049
- ? summaryLine.replace(/^-\s*(summary|mission|title):\s*/i, '')
10050
- : header.replace(/^\d{2}:\d{2}\s*·\s*/, '');
10067
+ const title = header.replace(/^\d{2}:\d{2}\s*·\s*/, '');
10068
+ if (isFeedFillerText(title)) continue;
10069
+ const field = (name) => {
10070
+ const line = lines.find((l) => new RegExp(`^-\\s*${name}:`, 'i').test(l));
10071
+ return line ? line.replace(new RegExp(`^-\\s*${name}:\\s*`, 'i'), '').trim() : '';
10072
+ };
10073
+ // No summary means nothing was recorded as changed: skip the row.
10074
+ const rawSummary = field('summary');
10075
+ if (isFeedFillerText(rawSummary)) continue;
10051
10076
  const cleanSummary = compactSentence(scrubFeedJargon(rawSummary), 140);
10052
- if (!cleanSummary) continue;
10053
- const alreadyInItems = items.some((item) => item.member === slug && item.summary === cleanSummary);
10054
- if (alreadyInItems) continue;
10055
- const passed = verifierLine ? /passed/i.test(verifierLine) : false;
10056
- const meta = metaByMember.get(slug) || { skills: [], goalWhy: null, goalTitle: null };
10057
- const rawNextPush = nextPushLine
10058
- ? nextPushLine.replace(/^-\s*(next_push|next push|next):\s*/i, '')
10059
- : (meta.goalTitle || '');
10077
+ if (!cleanSummary || isFeedFillerText(cleanSummary)) continue;
10078
+ if (items.some((item) => item.member === slug && item.summary === cleanSummary)) continue;
10079
+ const verifierState = (field('verifier').match(/^(passed|failed)/i)?.[1] || '').toLowerCase();
10080
+ const passed = verifierState === 'passed';
10081
+ const verifierCommand = verifierByObjective.get(`${slug}\u0000${field('mission')}`) || '';
10082
+ const rawNextPush = field('next_push') || field('next push');
10083
+ const nextPush = isFeedFillerText(rawNextPush) ? '' : compactSentence(scrubFeedJargon(rawNextPush), 110);
10084
+ const atMs = feedEntryTimeMs(file, header, mtimeMs);
10060
10085
  const impactChain = buildMemberImpactChain({
10061
10086
  summary: cleanSummary,
10062
10087
  verified: passed,
10063
- goalWhy: meta.goalWhy,
10064
- nextPush: rawNextPush,
10065
- skills: meta.skills,
10088
+ goalWhy,
10089
+ nextPush,
10090
+ skills,
10066
10091
  });
10067
10092
  items.push({
10068
10093
  member: slug,
10069
10094
  role: record.role || 'Team Member',
10070
- at: new Date(mtimeMs).toISOString(),
10071
- at_ms: mtimeMs,
10072
- age: ageLabelFromMs(mtimeMs),
10095
+ at: new Date(atMs).toISOString(),
10096
+ at_ms: atMs,
10097
+ age: ageLabelFromMs(atMs),
10073
10098
  summary: cleanSummary,
10074
- status: passed ? 'verified and ready' : 'logged update',
10099
+ checked: plainFeedCheckLabel(verifierState, verifierCommand),
10100
+ status: passed ? 'verified and ready' : verifierState === 'failed' ? 'check failed' : 'not checked yet',
10075
10101
  verified: passed,
10076
- next_push: impactChain.compounding,
10102
+ next_push: nextPush || null,
10077
10103
  impact_chain: impactChain,
10078
10104
  });
10079
10105
  }
@@ -10085,6 +10111,14 @@ function collectMemberWorkFeed(root = process.cwd(), options = {}) {
10085
10111
  .slice(0, limit);
10086
10112
  }
10087
10113
 
10114
+ function formatMemberFeedRow(item, indent = '') {
10115
+ const tail = item.next_push ? ` · next: ${item.next_push}` : '';
10116
+ return [
10117
+ `${indent}${item.age.padEnd(4)} · ${item.member}: ${item.summary}`,
10118
+ `${indent} ${item.checked}${tail}`,
10119
+ ];
10120
+ }
10121
+
10088
10122
  function memberFeed(...args) {
10089
10123
  if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
10090
10124
  console.log('Usage: atris member feed [--member <name>] [--limit N] [--json]');
@@ -10096,14 +10130,8 @@ function memberFeed(...args) {
10096
10130
  const limit = readNumberFlag(args, '--limit', 15);
10097
10131
  const feed = collectMemberWorkFeed(process.cwd(), { member: memberFilter, limit });
10098
10132
  const lines = !feed.length
10099
- ? ['No team work recorded yet. Run: atris member dispatch "<idea>"']
10100
- : [
10101
- 'Team work feed',
10102
- ...feed.flatMap((item) => [
10103
- `${item.age.padEnd(4)} · ${item.member} (${item.role}): ${item.summary} [${item.status}]`,
10104
- ` -> ${item.impact_chain.consequence} -> Next push: ${item.impact_chain.compounding}`,
10105
- ]),
10106
- ];
10133
+ ? ['no real work yet']
10134
+ : ['Team work feed', ...feed.flatMap((item) => formatMemberFeedRow(item))];
10107
10135
  printJsonOrText(
10108
10136
  {
10109
10137
  ok: true,
@@ -10828,9 +10856,10 @@ async function memberRoom(...args) {
10828
10856
  if (hasFlag(args, '--walk') || Boolean(readFlag(args, '--decisions', ''))) {
10829
10857
  return memberStandupWalk(room, args);
10830
10858
  }
10831
- const engineSummary = room.engines.length
10832
- ? room.engines.map((e) => `${e.id}(${e.tier})`).join(', ')
10833
- : 'claude, codex';
10859
+ const teamEngines = [...new Set(room.standup.map((s) => s.engine).filter((e) => e && e !== 'auto'))];
10860
+ const engineLine = teamEngines.length
10861
+ ? `Your team runs on ${teamEngines.length > 1 ? `${teamEngines.slice(0, -1).join(', ')} and ${teamEngines[teamEngines.length - 1]}` : teamEngines[0]}.`
10862
+ : 'Your team picks an engine for each job.';
10834
10863
 
10835
10864
  const standupRows = room.standup.flatMap((s, idx) => [
10836
10865
  `+-----------------------------------------------------------------------+`,
@@ -10849,16 +10878,12 @@ async function memberRoom(...args) {
10849
10878
  : [' - Zero blocking 1-on-1s today. Every member has an active roadmap and engine seat.'];
10850
10879
 
10851
10880
  const feedRows = room.feed.length
10852
- ? room.feed.slice(0, 3).flatMap((item) => [
10853
- ` ${item.age.padEnd(4)} · ${item.member}: ${item.summary} [${item.status}]`,
10854
- ` -> ${item.impact_chain.consequence} -> Next push: ${item.impact_chain.compounding}`,
10855
- ])
10856
- : [' No work recorded yet. Run: atris member go'];
10881
+ ? room.feed.slice(0, 3).flatMap((item) => formatMemberFeedRow(item, ' '))
10882
+ : [' no real work yet'];
10857
10883
 
10858
10884
  const lines = [
10859
10885
  'Your team · workday briefing',
10860
- `Engines ready: ${engineSummary}`,
10861
- `Loop: ${room.utopia.loop}`,
10886
+ engineLine,
10862
10887
  '',
10863
10888
  '1. Member-by-Member Standup (detail: `atris member card <name>` | rapid walk: `atris member standup --walk`):',
10864
10889
  ...standupRows,
@@ -10956,6 +10981,8 @@ async function memberCommand(subcommand, ...args) {
10956
10981
  case 'feed':
10957
10982
  case 'stream':
10958
10983
  return memberFeed(...args);
10984
+ case 'board':
10985
+ return require('../lib/member-board').memberBoardCommand(...args);
10959
10986
  case 'card':
10960
10987
  case 'cards':
10961
10988
  return memberCard(args[0], ...args.slice(1));
@@ -11015,6 +11042,7 @@ async function memberCommand(subcommand, ...args) {
11015
11042
  console.log(' 1on1 <name> Sit down for a 1-on-1 to review recent wins and set the roadmap');
11016
11043
  console.log(' sleep [name|--all] Park active member loops so your team rests until woken');
11017
11044
  console.log(' room [--json] Render the visual Team Room and desktop UI payload');
11045
+ console.log(' board [--watch] One screen of every member: job, last step, working/quiet/done');
11018
11046
  console.log(' create <name> Scaffold a new team member (MEMBER.md + dirs) [--push]');
11019
11047
  console.log(' install <slug> [--update] Install missing files, or refresh template skills');
11020
11048
  console.log(' chat <name> "..." Send a message to this member\'s desk in Atris Desktop');
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ // atris scoreboard: today's GitHub contributions as a small game.
4
+ // Data comes from `gh api graphql` (the contribution calendar) and `git log`
5
+ // for this repo. Node built-ins only.
6
+
7
+ const { execFileSync } = require('child_process');
8
+
9
+ const QUERY = 'query { viewer { contributionsCollection { contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }';
10
+ const MEMBER_TRAILER = 'atris-builder[bot]@users.noreply.github.com';
11
+ const BAR_WIDTH = 20;
12
+ const CHEER_STEP = 50;
13
+
14
+ function localDate(d = new Date()) {
15
+ const pad = (n) => String(n).padStart(2, '0');
16
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
17
+ }
18
+
19
+ function fetchDays() {
20
+ const out = execFileSync('gh', ['api', 'graphql', '-f', `query=${QUERY}`], {
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'pipe'],
23
+ });
24
+ const weeks = JSON.parse(out).data.viewer.contributionsCollection.contributionCalendar.weeks;
25
+ return weeks.flatMap((w) => w.contributionDays)
26
+ .map((d) => ({ date: d.date, count: d.contributionCount }))
27
+ .sort((a, b) => a.date.localeCompare(b.date));
28
+ }
29
+
30
+ function score(days, today = localDate()) {
31
+ const todayDay = days.find((d) => d.date === today) || { date: today, count: 0 };
32
+ const year = today.slice(0, 4);
33
+ let best = null;
34
+ for (const d of days) {
35
+ if (d.date === today || !d.date.startsWith(year)) continue;
36
+ if (!best || d.count > best.count) best = d;
37
+ }
38
+ // A streak survives a quiet morning: if today is still zero, count from yesterday.
39
+ const past = days.filter((d) => d.date <= today);
40
+ if (past.length && past[past.length - 1].date === today && todayDay.count === 0) past.pop();
41
+ let streak = 0;
42
+ for (let i = past.length - 1; i >= 0 && past[i].count > 0; i--) streak++;
43
+ return { today: todayDay.count, best, streak };
44
+ }
45
+
46
+ function bar(count, target) {
47
+ const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((count / target) * BAR_WIDTH)));
48
+ return `[${'#'.repeat(filled)}${'-'.repeat(BAR_WIDTH - filled)}]`;
49
+ }
50
+
51
+ // Today's commits on every ref (all local and remote branches, the checkout,
52
+ // tags), so an old branch checked out still sees today's work. Stash is left
53
+ // out. Each commit counts once, keyed by hash.
54
+ function repoCommits(cwd = process.cwd(), now = new Date()) {
55
+ const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
56
+ try {
57
+ const out = execFileSync('git', ['log', '--exclude=refs/stash', '--all', `--since=${midnight.toISOString()}`, '--format=%H%x1f%B%x1e'], {
58
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
59
+ });
60
+ const byHash = new Map();
61
+ for (const rec of out.split('\x1e')) {
62
+ const [hash, body = ''] = rec.trim().split('\x1f');
63
+ if (hash) byHash.set(hash, body);
64
+ }
65
+ const bodies = [...byHash.values()];
66
+ const members = bodies.filter((b) => b.includes(MEMBER_TRAILER)).length;
67
+ return { total: bodies.length, you: bodies.length - members, members };
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function render(s, commits) {
74
+ const lines = [`today: ${s.today} contribution${s.today === 1 ? '' : 's'}`];
75
+ if (s.best) {
76
+ const target = s.best.count + 1;
77
+ const note = s.today >= target ? 'new best day this year' : `${target - s.today} to beat your best`;
78
+ lines.push(`${bar(s.today, target)} ${note}`);
79
+ lines.push(`best day this year: ${s.best.date}, ${s.best.count}`);
80
+ } else {
81
+ lines.push('best day this year: none yet, today sets it');
82
+ }
83
+ lines.push(`streak: ${s.streak} day${s.streak === 1 ? '' : 's'}`);
84
+ lines.push(commits
85
+ ? `this repo today: ${commits.total} commit${commits.total === 1 ? '' : 's'}, you ${commits.you}, members ${commits.members}`
86
+ : 'this repo today: not a git repo');
87
+ return lines.join('\n');
88
+ }
89
+
90
+ // One cheer line per multiple of 50 crossed between two readings.
91
+ function cheers(prev, next) {
92
+ const out = [];
93
+ for (let m = (Math.floor(prev / CHEER_STEP) + 1) * CHEER_STEP; m <= next; m += CHEER_STEP) {
94
+ out.push(`nice, you passed ${m} today. keep pushing.`);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function snapshot() {
100
+ const s = score(fetchDays());
101
+ const commits = repoCommits();
102
+ return { s, commits, text: render(s, commits) };
103
+ }
104
+
105
+ // Same numbers as the board, as one object for scripts.
106
+ function toJson(s, commits, date = localDate()) {
107
+ return { date, today: s.today, best: s.best, streak: s.streak, repo: commits };
108
+ }
109
+
110
+ async function scoreboardCommand(args = []) {
111
+ if (args.includes('--help') || args.includes('-h')) {
112
+ console.log('usage: atris scoreboard [--watch | --json]');
113
+ console.log(' today\'s github contributions, your best day this year, your streak, and this repo\'s commits');
114
+ console.log(' --watch refresh every 60 seconds and cheer at every 50');
115
+ console.log(' --json print the same numbers once as one json object');
116
+ return 0;
117
+ }
118
+ let snap;
119
+ try {
120
+ snap = snapshot();
121
+ } catch {
122
+ console.error('could not read your github contributions. check that gh is installed and logged in (gh auth status).');
123
+ return 1;
124
+ }
125
+ if (args.includes('--json')) {
126
+ console.log(JSON.stringify(toJson(snap.s, snap.commits), null, 2));
127
+ return 0;
128
+ }
129
+ console.log(snap.text);
130
+ if (!args.includes('--watch')) return 0;
131
+
132
+ const intervalMs = Number(process.env.ATRIS_SCOREBOARD_INTERVAL_MS) || 60000;
133
+ const maxTicks = Number(process.env.ATRIS_SCOREBOARD_TICKS) || Infinity;
134
+ let last = snap.s.today;
135
+ for (let tick = 0; tick < maxTicks; tick++) {
136
+ await new Promise((r) => setTimeout(r, intervalMs));
137
+ try {
138
+ snap = snapshot();
139
+ } catch {
140
+ console.log('refresh failed, trying again next minute');
141
+ continue;
142
+ }
143
+ console.log('');
144
+ console.log(snap.text);
145
+ for (const line of cheers(last, snap.s.today)) console.log(line);
146
+ last = snap.s.today;
147
+ }
148
+ return 0;
149
+ }
150
+
151
+ module.exports = { scoreboardCommand, score, render, toJson, cheers, bar, localDate };
@@ -3,7 +3,7 @@
3
3
  const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
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
- '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',
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', 'scoreboard', 'sync-checkout',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
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', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
9
9
 
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+
3
+ // One screen of every member with a progress log: what job it is on, its last
4
+ // step, how long ago that was, and whether it is working, quiet, or done.
5
+ // Reads the logs lib/member-progress.js writes: "<iso> <mission> <text>".
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+
11
+ const QUIET_AFTER_MS = 2 * 60 * 1000;
12
+ const HIDE_DONE_AFTER_MS = 60 * 60 * 1000;
13
+ const TAIL_BYTES = 256 * 1024;
14
+ const JOB_WIDTH = 50;
15
+ const STEP_WIDTH = 50;
16
+
17
+ function progressDir() {
18
+ return process.env.ATRIS_PROGRESS_DIR || path.join(os.homedir(), '.atris', 'progress');
19
+ }
20
+
21
+ function readTail(file) {
22
+ const fd = fs.openSync(file, 'r');
23
+ try {
24
+ const size = fs.fstatSync(fd).size;
25
+ const start = Math.max(0, size - TAIL_BYTES);
26
+ const buf = Buffer.alloc(size - start);
27
+ fs.readSync(fd, buf, 0, buf.length, start);
28
+ const text = buf.toString('utf8');
29
+ // A cut read starts mid-line; drop the partial first line.
30
+ return start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
31
+ } finally {
32
+ fs.closeSync(fd);
33
+ }
34
+ }
35
+
36
+ function parseLine(raw) {
37
+ const m = /^(\S+) (\S+) (.*)$/.exec(raw);
38
+ if (!m) return null;
39
+ const at = Date.parse(m[1]);
40
+ if (Number.isNaN(at)) return null;
41
+ return { at, text: m[3] };
42
+ }
43
+
44
+ function cut(text, width) {
45
+ const s = String(text || '').replace(/\s+/g, ' ').trim();
46
+ return s.length > width ? `${s.slice(0, width - 3)}...` : s;
47
+ }
48
+
49
+ function ago(ms) {
50
+ const s = Math.max(0, Math.floor(ms / 1000));
51
+ if (s < 60) return `${s}s ago`;
52
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
53
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
54
+ return `${Math.floor(s / 86400)}d ago`;
55
+ }
56
+
57
+ function escapeRe(s) {
58
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
59
+ }
60
+
61
+ // The current run is everything from the latest "started:" line on.
62
+ // Steps that only write the member's own log are housekeeping, so the last
63
+ // step column skips them unless nothing else happened.
64
+ function memberRow(name, text, now) {
65
+ const lines = text.split('\n').map(parseLine).filter(Boolean);
66
+ if (!lines.length) return null;
67
+ let start = 0;
68
+ for (let i = lines.length - 1; i >= 0; i--) {
69
+ if (lines[i].text.startsWith('started:')) { start = i; break; }
70
+ }
71
+ const run = lines.slice(start);
72
+ const startedLine = run[0].text.startsWith('started:') ? run[0] : null;
73
+ const finished = [...run].reverse().find((l) => l.text.startsWith('finished:'));
74
+ const ownLog = new RegExp(`atris/team/${escapeRe(name)}/logs\\b`);
75
+ const steps = run.filter((l) => !l.text.startsWith('started:') && !l.text.startsWith('finished:'));
76
+ const real = steps.filter((l) => !ownLog.test(l.text));
77
+ const step = real[real.length - 1] || steps[steps.length - 1];
78
+ const last = run[run.length - 1];
79
+ let state;
80
+ if (finished) state = /^finished:\s*ok\b/.test(finished.text) ? 'done ok' : 'done failed';
81
+ else state = now - last.at < QUIET_AFTER_MS ? 'working' : 'quiet';
82
+ return {
83
+ member: name,
84
+ state,
85
+ job: startedLine ? cut(startedLine.text.slice('started:'.length), JOB_WIDTH) : '',
86
+ step: step ? cut(step.text, STEP_WIDTH) : '',
87
+ ago: ago(now - last.at),
88
+ at: last.at,
89
+ // Finished more than an hour ago: off the board unless --all.
90
+ idle: last.text.startsWith('finished:') && now - last.at >= HIDE_DONE_AFTER_MS,
91
+ };
92
+ }
93
+
94
+ function readBoard({ dir = progressDir(), now = Date.now(), all = false } = {}) {
95
+ let files = [];
96
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.log')); } catch {}
97
+ const rows = [];
98
+ for (const f of files) {
99
+ let text = '';
100
+ try { text = readTail(path.join(dir, f)); } catch { continue; }
101
+ const row = memberRow(f.slice(0, -'.log'.length), text, now);
102
+ if (row) rows.push(row);
103
+ }
104
+ return rows.filter((r) => all || !r.idle).sort((a, b) => b.at - a.at);
105
+ }
106
+
107
+ function renderBoard(rows, dir = progressDir(), hidden = 0) {
108
+ const note = hidden ? `${hidden} finished over an hour ago, hidden. --all shows them` : '';
109
+ if (!rows.length) return note ? `nobody is working right now. ${note}` : `no member progress yet in ${dir}`;
110
+ const w = (key, min) => Math.max(min, ...rows.map((r) => r[key].length));
111
+ const nameW = w('member', 6);
112
+ const stateW = w('state', 5);
113
+ const agoW = w('ago', 3);
114
+ const jobW = Math.max(3, ...rows.map((r) => (r.job || '-').length));
115
+ const pad = (s, n) => s.padEnd(n);
116
+ const line = (a, b, c, d, e) => `${pad(a, nameW)} ${pad(b, stateW)} ${pad(c, agoW)} ${pad(d, jobW)} ${e}`.trimEnd();
117
+ const out = [line('member', 'state', 'ago', 'job', 'last step')];
118
+ for (const r of rows) out.push(line(r.member, r.state, r.ago, r.job || '-', r.step || '-'));
119
+ if (note) out.push('', note);
120
+ return out.join('\n');
121
+ }
122
+
123
+ // Live stream format for `atris member board --json --follow`, one JSON
124
+ // object per line on stdout, forever:
125
+ // first line: {"kind":"snapshot","at":"<iso>","dir":"<progress dir>","members":[<same rows as --json>]}
126
+ // then one line per new progress line, in the order it was read:
127
+ // {"member":"builder","mission":"m-1","at":"<iso>","kind":"started","text":"<job>"}
128
+ // {"member":"builder","mission":"m-1","at":"<iso>","kind":"step","text":"<step>"}
129
+ // {"member":"builder","mission":"m-1","at":"<iso>","kind":"finished","text":"ok","ok":true}
130
+ // "ok" appears only on finished. Lines that are not "<iso> <mission> <text>"
131
+ // are skipped, and a line is only read once its newline has been written.
132
+ // Stops on ctrl-c, or on the next write after the reader closes the pipe;
133
+ // an app that is done should kill the process.
134
+ function progressEvent(member, raw) {
135
+ const m = /^(\S+) (\S+) (.*)$/.exec(raw);
136
+ if (!m) return null;
137
+ const at = Date.parse(m[1]);
138
+ if (Number.isNaN(at)) return null;
139
+ const event = { member, mission: m[2], at: new Date(at).toISOString(), kind: 'step', text: m[3] };
140
+ const tagged = /^(started|finished):\s*(.*)$/.exec(m[3]);
141
+ if (tagged) {
142
+ event.kind = tagged[1];
143
+ event.text = tagged[2];
144
+ if (tagged[1] === 'finished') event.ok = /^ok\b/.test(tagged[2]);
145
+ }
146
+ return event;
147
+ }
148
+
149
+ // Tails every <member>.log in dir and calls emit(event) for each new full
150
+ // line. Files present at start are read from just after their last newline,
151
+ // so a line half written before the stream started still arrives once done;
152
+ // files that appear later are read from the top. scan() is sync, so watch
153
+ // and poll can both call it without racing.
154
+ function afterLastNewline(file) {
155
+ let fd;
156
+ try { fd = fs.openSync(file, 'r'); } catch { return 0; }
157
+ try {
158
+ const buf = Buffer.alloc(64 * 1024);
159
+ for (let end = fs.fstatSync(fd).size; end > 0;) {
160
+ const start = Math.max(0, end - buf.length);
161
+ const n = fs.readSync(fd, buf, 0, end - start, start);
162
+ const i = buf.subarray(0, n).lastIndexOf(0x0a);
163
+ if (i >= 0) return start + i + 1;
164
+ end = start;
165
+ }
166
+ return 0;
167
+ } catch { return 0; } finally { fs.closeSync(fd); }
168
+ }
169
+
170
+ function followBoard({ dir = progressDir(), emit, pollMs = 1000 } = {}) {
171
+ const offsets = new Map();
172
+ const sizeOf = (file) => { try { return fs.statSync(file).size; } catch { return null; } };
173
+ const list = () => { try { return fs.readdirSync(dir).filter((f) => f.endsWith('.log')); } catch { return []; } };
174
+ for (const f of list()) offsets.set(f, afterLastNewline(path.join(dir, f)));
175
+
176
+ function readNew(f) {
177
+ const file = path.join(dir, f);
178
+ const size = sizeOf(file);
179
+ if (size === null) return;
180
+ let from = offsets.get(f) || 0;
181
+ if (size < from) from = 0; // truncated or replaced: start over
182
+ if (size === from) { offsets.set(f, from); return; }
183
+ let buf;
184
+ try {
185
+ const fd = fs.openSync(file, 'r');
186
+ try {
187
+ buf = Buffer.alloc(size - from);
188
+ buf = buf.subarray(0, fs.readSync(fd, buf, 0, buf.length, from));
189
+ } finally { fs.closeSync(fd); }
190
+ } catch { return; }
191
+ // Stop at the last newline; a half-written tail waits for the next scan.
192
+ const end = buf.lastIndexOf(0x0a);
193
+ if (end < 0) { offsets.set(f, from); return; }
194
+ offsets.set(f, from + end + 1);
195
+ const member = f.slice(0, -'.log'.length);
196
+ for (const raw of buf.subarray(0, end).toString('utf8').split('\n')) {
197
+ const event = progressEvent(member, raw.replace(/\r$/, ''));
198
+ if (event) emit(event);
199
+ }
200
+ }
201
+
202
+ let watcher = null;
203
+ let stopped = false;
204
+ function scan() {
205
+ if (stopped) return;
206
+ if (!watcher) {
207
+ try {
208
+ watcher = fs.watch(dir, () => scan());
209
+ watcher.on('error', () => { try { watcher.close(); } catch {} watcher = null; });
210
+ } catch { watcher = null; }
211
+ }
212
+ for (const f of list()) readNew(f);
213
+ }
214
+ const timer = setInterval(scan, pollMs);
215
+ scan();
216
+ return {
217
+ scan,
218
+ stop() {
219
+ stopped = true;
220
+ clearInterval(timer);
221
+ if (watcher) { try { watcher.close(); } catch {} }
222
+ },
223
+ };
224
+ }
225
+
226
+ function memberBoardCommand(...args) {
227
+ const asJson = args.includes('--json');
228
+ const all = args.includes('--all');
229
+ if (args.includes('--follow')) {
230
+ const dir = progressDir();
231
+ const write = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
232
+ write({ kind: 'snapshot', at: new Date().toISOString(), dir, members: readBoard({ dir, all }) });
233
+ const follower = followBoard({ dir, emit: write });
234
+ // Runs until ctrl-c or the reader closes the pipe.
235
+ return new Promise((resolve) => {
236
+ const done = () => { follower.stop(); resolve(); };
237
+ process.once('SIGINT', done);
238
+ process.stdout.once('error', done);
239
+ });
240
+ }
241
+ if (asJson) {
242
+ console.log(JSON.stringify({ ok: true, dir: progressDir(), members: readBoard({ all }) }, null, 2));
243
+ return;
244
+ }
245
+ const draw = () => {
246
+ const every = readBoard({ all: true });
247
+ const rows = all ? every : every.filter((r) => !r.idle);
248
+ return renderBoard(rows, progressDir(), every.length - rows.length);
249
+ };
250
+ if (!args.includes('--watch')) {
251
+ console.log(draw());
252
+ return;
253
+ }
254
+ const redraw = () => process.stdout.write(`\x1b[2J\x1b[H${draw()}\n\nredraws every 3s, ctrl-c to stop\n`);
255
+ redraw();
256
+ // The member route exits once this settles, so only settle on ctrl-c.
257
+ return new Promise((resolve) => {
258
+ const timer = setInterval(redraw, 3000);
259
+ process.once('SIGINT', () => { clearInterval(timer); resolve(); });
260
+ });
261
+ }
262
+
263
+ module.exports = { readBoard, renderBoard, memberRow, ago, progressEvent, followBoard, memberBoardCommand };
@@ -62,21 +62,33 @@ function hhmm(date = new Date()) {
62
62
  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
63
63
  }
64
64
 
65
+ // One progress line stays one short line: a 'started:' objective keeps its
66
+ // first line and at most 80 chars; any other line caps at 100.
67
+ function shortenLine(line) {
68
+ const first = String(line || '').split('\n')[0];
69
+ if (first.startsWith('started: ')) {
70
+ const objective = first.slice('started: '.length);
71
+ return `started: ${objective.length > 80 ? `${objective.slice(0, 80)}...` : objective}`;
72
+ }
73
+ return first.length > 100 ? `${first.slice(0, 100)}...` : first;
74
+ }
75
+
65
76
  function appendProgress(member, line, missionId) {
66
77
  // Test runs never write into the real home folder unless they name a dir.
67
78
  if (!process.env.ATRIS_PROGRESS_DIR && process.env.NODE_TEST_CONTEXT) return;
68
79
  try {
69
80
  const dir = progressDir();
70
81
  fs.mkdirSync(dir, { recursive: true });
71
- const entry = `${new Date().toISOString()} ${missionId || '-'} ${line}\n`;
82
+ const entry = `${new Date().toISOString()} ${missionId || '-'} ${shortenLine(line)}\n`;
72
83
  fs.appendFileSync(path.join(dir, `${safeMember(member)}.log`), entry, 'utf8');
73
84
  } catch {}
74
85
  }
75
86
 
76
87
  // Print one step for the operator and keep it in the member's progress log.
77
88
  function reportProgress(member, line, missionId) {
78
- try { process.stderr.write(` ${member || 'mission'} ${hhmm()} ${line}\n`); } catch {}
79
- appendProgress(member, line, missionId);
89
+ const short = shortenLine(line);
90
+ try { process.stderr.write(` ${member || 'mission'} ${hhmm()} ${short}\n`); } catch {}
91
+ appendProgress(member, short, missionId);
80
92
  }
81
93
 
82
- module.exports = { formatToolStep, appendProgress, reportProgress };
94
+ module.exports = { formatToolStep, appendProgress, reportProgress, shortenLine };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.68.5",
3
+ "version": "3.68.7",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {