atris 3.68.4 → 3.68.6
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 +5 -0
- package/commands/member.js +101 -93
- package/commands/mission.js +13 -0
- package/commands/scoreboard.js +132 -0
- package/lib/engine-registry.js +15 -96
- package/lib/known-commands.js +1 -1
- package/lib/member-board.js +129 -0
- package/lib/member-progress.js +82 -0
- package/lib/model-names.js +116 -0
- package/lib/runner-command.js +11 -10
- package/package.json +1 -1
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)');
|
|
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))
|
package/commands/member.js
CHANGED
|
@@ -9937,6 +9937,7 @@ function scrubFeedJargon(text) {
|
|
|
9937
9937
|
return String(text || '')
|
|
9938
9938
|
.replace(/\bmission-\d{4}-\d{2}-\d{2}-[a-z0-9-]+\b/gi, '')
|
|
9939
9939
|
.replace(/\b[0-9a-f]{7,40}\b/g, '')
|
|
9940
|
+
.replace(/\(\s*(commit\s*)?\)/gi, '')
|
|
9940
9941
|
.replace(/\s+/g, ' ')
|
|
9941
9942
|
.trim();
|
|
9942
9943
|
}
|
|
@@ -9966,68 +9967,65 @@ function buildMemberImpactChain({ summary, verified, goalWhy, nextPush, skills =
|
|
|
9966
9967
|
};
|
|
9967
9968
|
}
|
|
9968
9969
|
|
|
9970
|
+
// Rows that only look like work: placeholder goals, wake and blocked
|
|
9971
|
+
// notices, empty "logged update" rows, and raw commands.
|
|
9972
|
+
function isFeedFillerText(text) {
|
|
9973
|
+
const t = String(text || '').trim();
|
|
9974
|
+
if (!t) return true;
|
|
9975
|
+
if (/^serve the team as\b/i.test(t)) return true;
|
|
9976
|
+
if (/^member (wake|goal-from-mission|goal created|initialized)\b/i.test(t)) return true;
|
|
9977
|
+
if (/^\[?logged update\]?$/i.test(t)) return true;
|
|
9978
|
+
if (/^(next[_ ]push|verdict):/i.test(t)) return true;
|
|
9979
|
+
if (/^[`'"]?atris\s/i.test(t) || /\batris\s+[a-z-]+\s+[a-z-]+\s+--[a-z]/i.test(t)) return true;
|
|
9980
|
+
return false;
|
|
9981
|
+
}
|
|
9982
|
+
|
|
9983
|
+
// How a row was checked, in words a reader can follow without the command.
|
|
9984
|
+
function plainFeedCheckLabel(state, verifierCommand = '') {
|
|
9985
|
+
if (state === 'failed') return 'check failed, needs another pass';
|
|
9986
|
+
if (state !== 'passed') return 'not checked yet';
|
|
9987
|
+
const cmd = String(verifierCommand || '');
|
|
9988
|
+
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';
|
|
9989
|
+
if (/git\s+diff\s+--check/i.test(cmd)) return 'checked: the change has no whitespace errors';
|
|
9990
|
+
if (/\b(lint|eslint|tsc)\b/i.test(cmd)) return 'checked: lint passed';
|
|
9991
|
+
return 'checked: its check passed';
|
|
9992
|
+
}
|
|
9993
|
+
|
|
9994
|
+
function feedEntryTimeMs(file, header, fallbackMs) {
|
|
9995
|
+
const day = String(file).match(/^(\d{4})-(\d{2})-(\d{2})\.md$/);
|
|
9996
|
+
const clock = String(header).match(/^(\d{2}):(\d{2})\b/);
|
|
9997
|
+
if (!day) return fallbackMs;
|
|
9998
|
+
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();
|
|
9999
|
+
return Number.isFinite(ms) ? Math.min(ms, fallbackMs || ms) : fallbackMs;
|
|
10000
|
+
}
|
|
10001
|
+
|
|
10002
|
+
// The feed reads member log entries that say what changed. Mission records
|
|
10003
|
+
// only hold the assignment, so they are used to name how the work was checked.
|
|
9969
10004
|
function collectMemberWorkFeed(root = process.cwd(), options = {}) {
|
|
9970
10005
|
const teamDir = path.join(root, 'atris', 'team');
|
|
9971
10006
|
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
10007
|
const filterMember = String(options.member || '').trim().toLowerCase();
|
|
9988
10008
|
const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0 ? Number(options.limit) : 15;
|
|
9989
10009
|
|
|
9990
|
-
const
|
|
9991
|
-
const
|
|
9992
|
-
|
|
9993
|
-
|
|
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
|
-
});
|
|
10010
|
+
const verifierByObjective = new Map();
|
|
10011
|
+
for (const mission of memberRunMissionMap(root).values()) {
|
|
10012
|
+
const objective = String(mission?.objective || '').trim();
|
|
10013
|
+
if (objective && mission?.verifier) verifierByObjective.set(`${mission.owner}\u0000${objective}`, String(mission.verifier));
|
|
10023
10014
|
}
|
|
10024
10015
|
|
|
10016
|
+
const items = [];
|
|
10025
10017
|
for (const record of members) {
|
|
10026
10018
|
const slug = memberSlugFromRecord(record);
|
|
10027
10019
|
if (filterMember && slug.toLowerCase() !== filterMember) continue;
|
|
10028
10020
|
const logsDir = path.join(teamDir, slug, 'logs');
|
|
10029
10021
|
if (!fs.existsSync(logsDir)) continue;
|
|
10030
|
-
const
|
|
10022
|
+
const mDir = path.join(teamDir, slug);
|
|
10023
|
+
let goalWhy = null;
|
|
10024
|
+
try {
|
|
10025
|
+
goalWhy = memberGoalPlaneStatus(slug, { memberDir: mDir, goalsFile: path.join(mDir, 'goals.json') })?.goal?.why || null;
|
|
10026
|
+
} catch {}
|
|
10027
|
+
const skills = listMemberUnlockedSkills(mDir, record.skills);
|
|
10028
|
+
const logFiles = fs.readdirSync(logsDir).filter((f) => f.endsWith('.md')).sort().slice(-7);
|
|
10031
10029
|
for (const file of logFiles) {
|
|
10032
10030
|
const fullPath = path.join(logsDir, file);
|
|
10033
10031
|
let raw = '';
|
|
@@ -10038,42 +10036,45 @@ function collectMemberWorkFeed(root = process.cwd(), options = {}) {
|
|
|
10038
10036
|
} catch {
|
|
10039
10037
|
continue;
|
|
10040
10038
|
}
|
|
10041
|
-
const
|
|
10042
|
-
for (const section of sections.slice(-3)) {
|
|
10039
|
+
for (const section of raw.split(/^##\s+/m).slice(1)) {
|
|
10043
10040
|
const lines = section.trim().split(/\r?\n/);
|
|
10044
10041
|
const header = lines[0] || '';
|
|
10045
|
-
const
|
|
10046
|
-
|
|
10047
|
-
const
|
|
10048
|
-
|
|
10049
|
-
?
|
|
10050
|
-
|
|
10042
|
+
const title = header.replace(/^\d{2}:\d{2}\s*·\s*/, '');
|
|
10043
|
+
if (isFeedFillerText(title)) continue;
|
|
10044
|
+
const field = (name) => {
|
|
10045
|
+
const line = lines.find((l) => new RegExp(`^-\\s*${name}:`, 'i').test(l));
|
|
10046
|
+
return line ? line.replace(new RegExp(`^-\\s*${name}:\\s*`, 'i'), '').trim() : '';
|
|
10047
|
+
};
|
|
10048
|
+
// No summary means nothing was recorded as changed: skip the row.
|
|
10049
|
+
const rawSummary = field('summary');
|
|
10050
|
+
if (isFeedFillerText(rawSummary)) continue;
|
|
10051
10051
|
const cleanSummary = compactSentence(scrubFeedJargon(rawSummary), 140);
|
|
10052
|
-
if (!cleanSummary) continue;
|
|
10053
|
-
|
|
10054
|
-
|
|
10055
|
-
const passed =
|
|
10056
|
-
const
|
|
10057
|
-
const rawNextPush =
|
|
10058
|
-
|
|
10059
|
-
|
|
10052
|
+
if (!cleanSummary || isFeedFillerText(cleanSummary)) continue;
|
|
10053
|
+
if (items.some((item) => item.member === slug && item.summary === cleanSummary)) continue;
|
|
10054
|
+
const verifierState = (field('verifier').match(/^(passed|failed)/i)?.[1] || '').toLowerCase();
|
|
10055
|
+
const passed = verifierState === 'passed';
|
|
10056
|
+
const verifierCommand = verifierByObjective.get(`${slug}\u0000${field('mission')}`) || '';
|
|
10057
|
+
const rawNextPush = field('next_push') || field('next push');
|
|
10058
|
+
const nextPush = isFeedFillerText(rawNextPush) ? '' : compactSentence(scrubFeedJargon(rawNextPush), 110);
|
|
10059
|
+
const atMs = feedEntryTimeMs(file, header, mtimeMs);
|
|
10060
10060
|
const impactChain = buildMemberImpactChain({
|
|
10061
10061
|
summary: cleanSummary,
|
|
10062
10062
|
verified: passed,
|
|
10063
|
-
goalWhy
|
|
10064
|
-
nextPush
|
|
10065
|
-
skills
|
|
10063
|
+
goalWhy,
|
|
10064
|
+
nextPush,
|
|
10065
|
+
skills,
|
|
10066
10066
|
});
|
|
10067
10067
|
items.push({
|
|
10068
10068
|
member: slug,
|
|
10069
10069
|
role: record.role || 'Team Member',
|
|
10070
|
-
at: new Date(
|
|
10071
|
-
at_ms:
|
|
10072
|
-
age: ageLabelFromMs(
|
|
10070
|
+
at: new Date(atMs).toISOString(),
|
|
10071
|
+
at_ms: atMs,
|
|
10072
|
+
age: ageLabelFromMs(atMs),
|
|
10073
10073
|
summary: cleanSummary,
|
|
10074
|
-
|
|
10074
|
+
checked: plainFeedCheckLabel(verifierState, verifierCommand),
|
|
10075
|
+
status: passed ? 'verified and ready' : verifierState === 'failed' ? 'check failed' : 'not checked yet',
|
|
10075
10076
|
verified: passed,
|
|
10076
|
-
next_push:
|
|
10077
|
+
next_push: nextPush || null,
|
|
10077
10078
|
impact_chain: impactChain,
|
|
10078
10079
|
});
|
|
10079
10080
|
}
|
|
@@ -10085,6 +10086,14 @@ function collectMemberWorkFeed(root = process.cwd(), options = {}) {
|
|
|
10085
10086
|
.slice(0, limit);
|
|
10086
10087
|
}
|
|
10087
10088
|
|
|
10089
|
+
function formatMemberFeedRow(item, indent = '') {
|
|
10090
|
+
const tail = item.next_push ? ` · next: ${item.next_push}` : '';
|
|
10091
|
+
return [
|
|
10092
|
+
`${indent}${item.age.padEnd(4)} · ${item.member}: ${item.summary}`,
|
|
10093
|
+
`${indent} ${item.checked}${tail}`,
|
|
10094
|
+
];
|
|
10095
|
+
}
|
|
10096
|
+
|
|
10088
10097
|
function memberFeed(...args) {
|
|
10089
10098
|
if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
|
|
10090
10099
|
console.log('Usage: atris member feed [--member <name>] [--limit N] [--json]');
|
|
@@ -10096,14 +10105,8 @@ function memberFeed(...args) {
|
|
|
10096
10105
|
const limit = readNumberFlag(args, '--limit', 15);
|
|
10097
10106
|
const feed = collectMemberWorkFeed(process.cwd(), { member: memberFilter, limit });
|
|
10098
10107
|
const lines = !feed.length
|
|
10099
|
-
? ['
|
|
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
|
-
];
|
|
10108
|
+
? ['no real work yet']
|
|
10109
|
+
: ['Team work feed', ...feed.flatMap((item) => formatMemberFeedRow(item))];
|
|
10107
10110
|
printJsonOrText(
|
|
10108
10111
|
{
|
|
10109
10112
|
ok: true,
|
|
@@ -10499,16 +10502,21 @@ async function memberGo(...rawArgs) {
|
|
|
10499
10502
|
const root = process.cwd();
|
|
10500
10503
|
const teamDir = path.join(root, 'atris', 'team');
|
|
10501
10504
|
const coreOrder = ['auto-improver', 'executor', 'navigator', 'validator', 'architect', 'customer-lead', 'researcher'];
|
|
10502
|
-
let chosen = '
|
|
10505
|
+
let chosen = '';
|
|
10503
10506
|
for (const slug of coreOrder) {
|
|
10504
10507
|
const mDir = path.join(teamDir, slug);
|
|
10505
10508
|
if (!fs.existsSync(path.join(mDir, 'MEMBER.md'))) continue;
|
|
10506
|
-
|
|
10509
|
+
if (!chosen) chosen = slug;
|
|
10510
|
+
const gp = memberGoalPlaneStatus(slug, { memberDir: mDir, goalsJson: path.join(mDir, 'goals.json') });
|
|
10507
10511
|
if (gp?.goal?.next_push || gp?.goal?.title) {
|
|
10508
10512
|
chosen = slug;
|
|
10509
10513
|
break;
|
|
10510
10514
|
}
|
|
10511
10515
|
}
|
|
10516
|
+
if (!chosen) {
|
|
10517
|
+
const fallback = findAllMembers(teamDir).filter(isFleetMember)[0];
|
|
10518
|
+
if (fallback) chosen = memberSlugFromRecord(fallback);
|
|
10519
|
+
}
|
|
10512
10520
|
return memberSteerOrDispatch(chosen, ...rawArgs);
|
|
10513
10521
|
}
|
|
10514
10522
|
|
|
@@ -10823,9 +10831,10 @@ async function memberRoom(...args) {
|
|
|
10823
10831
|
if (hasFlag(args, '--walk') || Boolean(readFlag(args, '--decisions', ''))) {
|
|
10824
10832
|
return memberStandupWalk(room, args);
|
|
10825
10833
|
}
|
|
10826
|
-
const
|
|
10827
|
-
|
|
10828
|
-
|
|
10834
|
+
const teamEngines = [...new Set(room.standup.map((s) => s.engine).filter((e) => e && e !== 'auto'))];
|
|
10835
|
+
const engineLine = teamEngines.length
|
|
10836
|
+
? `Your team runs on ${teamEngines.length > 1 ? `${teamEngines.slice(0, -1).join(', ')} and ${teamEngines[teamEngines.length - 1]}` : teamEngines[0]}.`
|
|
10837
|
+
: 'Your team picks an engine for each job.';
|
|
10829
10838
|
|
|
10830
10839
|
const standupRows = room.standup.flatMap((s, idx) => [
|
|
10831
10840
|
`+-----------------------------------------------------------------------+`,
|
|
@@ -10844,16 +10853,12 @@ async function memberRoom(...args) {
|
|
|
10844
10853
|
: [' - Zero blocking 1-on-1s today. Every member has an active roadmap and engine seat.'];
|
|
10845
10854
|
|
|
10846
10855
|
const feedRows = room.feed.length
|
|
10847
|
-
? room.feed.slice(0, 3).flatMap((item) =>
|
|
10848
|
-
|
|
10849
|
-
` -> ${item.impact_chain.consequence} -> Next push: ${item.impact_chain.compounding}`,
|
|
10850
|
-
])
|
|
10851
|
-
: [' No work recorded yet. Run: atris member go'];
|
|
10856
|
+
? room.feed.slice(0, 3).flatMap((item) => formatMemberFeedRow(item, ' '))
|
|
10857
|
+
: [' no real work yet'];
|
|
10852
10858
|
|
|
10853
10859
|
const lines = [
|
|
10854
10860
|
'Your team · workday briefing',
|
|
10855
|
-
|
|
10856
|
-
`Loop: ${room.utopia.loop}`,
|
|
10861
|
+
engineLine,
|
|
10857
10862
|
'',
|
|
10858
10863
|
'1. Member-by-Member Standup (detail: `atris member card <name>` | rapid walk: `atris member standup --walk`):',
|
|
10859
10864
|
...standupRows,
|
|
@@ -10951,6 +10956,8 @@ async function memberCommand(subcommand, ...args) {
|
|
|
10951
10956
|
case 'feed':
|
|
10952
10957
|
case 'stream':
|
|
10953
10958
|
return memberFeed(...args);
|
|
10959
|
+
case 'board':
|
|
10960
|
+
return require('../lib/member-board').memberBoardCommand(...args);
|
|
10954
10961
|
case 'card':
|
|
10955
10962
|
case 'cards':
|
|
10956
10963
|
return memberCard(args[0], ...args.slice(1));
|
|
@@ -11010,6 +11017,7 @@ async function memberCommand(subcommand, ...args) {
|
|
|
11010
11017
|
console.log(' 1on1 <name> Sit down for a 1-on-1 to review recent wins and set the roadmap');
|
|
11011
11018
|
console.log(' sleep [name|--all] Park active member loops so your team rests until woken');
|
|
11012
11019
|
console.log(' room [--json] Render the visual Team Room and desktop UI payload');
|
|
11020
|
+
console.log(' board [--watch] One screen of every member: job, last step, working/quiet/done');
|
|
11013
11021
|
console.log(' create <name> Scaffold a new team member (MEMBER.md + dirs) [--push]');
|
|
11014
11022
|
console.log(' install <slug> [--update] Install missing files, or refresh template skills');
|
|
11015
11023
|
console.log(' chat <name> "..." Send a message to this member\'s desk in Atris Desktop');
|
package/commands/mission.js
CHANGED
|
@@ -8,6 +8,7 @@ const { spawn, spawnSync } = require('child_process');
|
|
|
8
8
|
const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
|
|
9
9
|
const { memberProcessPrompt } = require('../lib/member-context');
|
|
10
10
|
const { memberIdentityLine, missionObjectiveLine } = require('../lib/member-identity');
|
|
11
|
+
const { formatToolStep, appendProgress, reportProgress } = require('../lib/member-progress');
|
|
11
12
|
const {
|
|
12
13
|
compactErrorPayload,
|
|
13
14
|
compactSuccessPayload,
|
|
@@ -9377,6 +9378,9 @@ function spawnClaudeTick(mission, opts) {
|
|
|
9377
9378
|
env: { ...gitGuard.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
|
|
9378
9379
|
});
|
|
9379
9380
|
updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
|
|
9381
|
+
const progressMember = mission.owner || 'mission';
|
|
9382
|
+
const seenToolIds = new Set();
|
|
9383
|
+
appendProgress(progressMember, `started: ${mission.objective || ''}`, mission.id);
|
|
9380
9384
|
|
|
9381
9385
|
let stdoutBuf = '';
|
|
9382
9386
|
let observedSessionIds = new Set();
|
|
@@ -9417,6 +9421,14 @@ function spawnClaudeTick(mission, opts) {
|
|
|
9417
9421
|
try {
|
|
9418
9422
|
const ev = JSON.parse(line);
|
|
9419
9423
|
if (ev.session_id) observedSessionIds.add(ev.session_id);
|
|
9424
|
+
if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
|
|
9425
|
+
for (const block of ev.message.content) {
|
|
9426
|
+
const step = formatToolStep(block, cwd);
|
|
9427
|
+
if (!step || seenToolIds.has(block.id)) continue;
|
|
9428
|
+
if (block.id) seenToolIds.add(block.id);
|
|
9429
|
+
reportProgress(progressMember, step, mission.id);
|
|
9430
|
+
}
|
|
9431
|
+
}
|
|
9420
9432
|
if (ev.type === 'rate_limit_event' && ev.rate_limit_info) {
|
|
9421
9433
|
rateLimitInfo = ev.rate_limit_info;
|
|
9422
9434
|
}
|
|
@@ -9443,6 +9455,7 @@ function spawnClaudeTick(mission, opts) {
|
|
|
9443
9455
|
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
9444
9456
|
updateMissionLockOwner(missionLock, missionLock?.driverPid);
|
|
9445
9457
|
const ok = code === 0 && !isError && !timedOut && !aborted;
|
|
9458
|
+
appendProgress(progressMember, `finished: ${ok ? 'ok' : 'failed'}`, mission.id);
|
|
9446
9459
|
const errStr = stderr.slice(-2000);
|
|
9447
9460
|
const authExpired = !ok && missionRunnerAuthExpired(errStr, finalText, stdoutBuf);
|
|
9448
9461
|
resolve({
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
function repoCommits(cwd = process.cwd(), now = new Date()) {
|
|
52
|
+
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
53
|
+
try {
|
|
54
|
+
const out = execFileSync('git', ['log', `--since=${midnight.toISOString()}`, '--format=%B%x1e'], {
|
|
55
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
56
|
+
});
|
|
57
|
+
const bodies = out.split('\x1e').map((b) => b.trim()).filter(Boolean);
|
|
58
|
+
const members = bodies.filter((b) => b.includes(MEMBER_TRAILER)).length;
|
|
59
|
+
return { total: bodies.length, you: bodies.length - members, members };
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function render(s, commits) {
|
|
66
|
+
const lines = [`today: ${s.today} contribution${s.today === 1 ? '' : 's'}`];
|
|
67
|
+
if (s.best) {
|
|
68
|
+
const target = s.best.count + 1;
|
|
69
|
+
const note = s.today >= target ? 'new best day this year' : `${target - s.today} to beat your best`;
|
|
70
|
+
lines.push(`${bar(s.today, target)} ${note}`);
|
|
71
|
+
lines.push(`best day this year: ${s.best.date}, ${s.best.count}`);
|
|
72
|
+
} else {
|
|
73
|
+
lines.push('best day this year: none yet, today sets it');
|
|
74
|
+
}
|
|
75
|
+
lines.push(`streak: ${s.streak} day${s.streak === 1 ? '' : 's'}`);
|
|
76
|
+
lines.push(commits
|
|
77
|
+
? `this repo today: ${commits.total} commit${commits.total === 1 ? '' : 's'}, you ${commits.you}, members ${commits.members}`
|
|
78
|
+
: 'this repo today: not a git repo');
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// One cheer line per multiple of 50 crossed between two readings.
|
|
83
|
+
function cheers(prev, next) {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (let m = (Math.floor(prev / CHEER_STEP) + 1) * CHEER_STEP; m <= next; m += CHEER_STEP) {
|
|
86
|
+
out.push(`nice, you passed ${m} today. keep pushing.`);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function snapshot() {
|
|
92
|
+
const s = score(fetchDays());
|
|
93
|
+
return { s, text: render(s, repoCommits()) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function scoreboardCommand(args = []) {
|
|
97
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
98
|
+
console.log('usage: atris scoreboard [--watch]');
|
|
99
|
+
console.log(' today\'s github contributions, your best day this year, your streak, and this repo\'s commits');
|
|
100
|
+
console.log(' --watch refresh every 60 seconds and cheer at every 50');
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
let snap;
|
|
104
|
+
try {
|
|
105
|
+
snap = snapshot();
|
|
106
|
+
} catch {
|
|
107
|
+
console.error('could not read your github contributions. check that gh is installed and logged in (gh auth status).');
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
console.log(snap.text);
|
|
111
|
+
if (!args.includes('--watch')) return 0;
|
|
112
|
+
|
|
113
|
+
const intervalMs = Number(process.env.ATRIS_SCOREBOARD_INTERVAL_MS) || 60000;
|
|
114
|
+
const maxTicks = Number(process.env.ATRIS_SCOREBOARD_TICKS) || Infinity;
|
|
115
|
+
let last = snap.s.today;
|
|
116
|
+
for (let tick = 0; tick < maxTicks; tick++) {
|
|
117
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
118
|
+
try {
|
|
119
|
+
snap = snapshot();
|
|
120
|
+
} catch {
|
|
121
|
+
console.log('refresh failed, trying again next minute');
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log(snap.text);
|
|
126
|
+
for (const line of cheers(last, snap.s.today)) console.log(line);
|
|
127
|
+
last = snap.s.today;
|
|
128
|
+
}
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { scoreboardCommand, score, render, cheers, bar, localDate };
|
package/lib/engine-registry.js
CHANGED
|
@@ -15,6 +15,21 @@ const {
|
|
|
15
15
|
engineTakesModel,
|
|
16
16
|
engineEffortLevels,
|
|
17
17
|
} = require('./runner-command');
|
|
18
|
+
// The one model-name table, shared with runner-command: friendly spellings
|
|
19
|
+
// to the ids each cli accepts, plus retired-model upgrades.
|
|
20
|
+
const {
|
|
21
|
+
CLAUDE_FAMILY_ENGINES,
|
|
22
|
+
CLAUDE_MODEL_ALIASES,
|
|
23
|
+
RETIRED_CLAUDE_MODELS,
|
|
24
|
+
CLAUDE_MODEL_SPELLING,
|
|
25
|
+
CLAUDE_LONG_CONTEXT,
|
|
26
|
+
GROK_MODEL_SPELLING,
|
|
27
|
+
currentClaudeModel,
|
|
28
|
+
claudeModelId,
|
|
29
|
+
retiredModelsNamed,
|
|
30
|
+
normalizeRosterModel,
|
|
31
|
+
savedRosterModel,
|
|
32
|
+
} = require('./model-names');
|
|
18
33
|
// Through the module object so tests can see when ranking runs.
|
|
19
34
|
const routerBrain = require('./router-brain');
|
|
20
35
|
const { routerPickExplanation } = routerBrain;
|
|
@@ -584,102 +599,6 @@ function rosterPickExpired(pick, now = new Date()) {
|
|
|
584
599
|
return localDay(rosterDate(now)).getTime() > until.getTime();
|
|
585
600
|
}
|
|
586
601
|
|
|
587
|
-
// Claude-family engines run through the claude CLI, which rejects loose
|
|
588
|
-
// spellings like "opus-5.5". Assign saves the id the CLI accepts, and every
|
|
589
|
-
// read normalizes again, so older saved picks and hand edits work too.
|
|
590
|
-
const CLAUDE_FAMILY_ENGINES = Object.freeze(['claude', 'fable', 'haiku']);
|
|
591
|
-
const CLAUDE_MODEL_ALIASES = Object.freeze(['opus', 'sonnet', 'haiku', 'fable', 'opusplan', 'default']);
|
|
592
|
-
// A retired model runs as its replacement, so a saved pick keeps working
|
|
593
|
-
// after the upgrade. Retiring the next one is one line here.
|
|
594
|
-
const RETIRED_CLAUDE_MODELS = Object.freeze({
|
|
595
|
-
'claude-fable-5': 'claude-fable-5-1',
|
|
596
|
-
'claude-haiku-4-5': 'claude-haiku-5-5',
|
|
597
|
-
'claude-haiku-4-5-20251001': 'claude-haiku-5-5',
|
|
598
|
-
});
|
|
599
|
-
const CLAUDE_MODEL_SPELLING = /^(opus|sonnet|haiku|fable)[\s_-]*(\d+)(?:[\s._-]+(\d+))?$/;
|
|
600
|
-
const CLAUDE_LONG_CONTEXT = /\s*\[1m\]$/;
|
|
601
|
-
|
|
602
|
-
// Grok takes friendly names too: "grok 4.7 fast" is grok-4.7-build-fast and
|
|
603
|
-
// "grok 4.7" is grok-4.7. Any full grok- id passes through as typed.
|
|
604
|
-
const GROK_MODEL_SPELLING = /^grok[\s_-]*(\d+(?:\.\d+)?)(?:[\s_-]+(?:build[\s_-]+)?(fast))?$/;
|
|
605
|
-
|
|
606
|
-
function normalizeGrokModel(raw) {
|
|
607
|
-
const match = GROK_MODEL_SPELLING.exec(raw.toLowerCase());
|
|
608
|
-
if (match) return `grok-${match[1]}${match[2] ? '-build-fast' : ''}`;
|
|
609
|
-
if (/^grok-[a-z0-9][a-z0-9._-]*$/i.test(raw)) return raw;
|
|
610
|
-
throw new Error(`grok does not know the model "${raw}". use grok 4.7 fast, grok 4.7, or a full grok- id`);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// The model to run for a claude id: its replacement when it is retired,
|
|
614
|
-
// else the id as given. Keeps a "[1m]" tail.
|
|
615
|
-
function currentClaudeModel(value) {
|
|
616
|
-
const raw = String(value || '').trim();
|
|
617
|
-
const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
|
|
618
|
-
const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
|
|
619
|
-
const next = RETIRED_CLAUDE_MODELS[base.toLowerCase()];
|
|
620
|
-
return next ? `${next}${suffix}` : raw;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// The id the claude cli accepts for a display name typed with a space:
|
|
624
|
-
// "opus 5.5" is claude-opus-5-5, "fable 5.1" is claude-fable-5-1. A full id,
|
|
625
|
-
// a bare alias ("opus"), a "[1m]" tail and anything else pass through as given
|
|
626
|
-
// (a retired id still becomes its replacement).
|
|
627
|
-
const CLAUDE_DISPLAY_NAME = /^(opus|sonnet|haiku|fable)\s+(\d+)(?:\.(\d+))?$/i;
|
|
628
|
-
|
|
629
|
-
function claudeModelId(value) {
|
|
630
|
-
const raw = String(value || '').trim();
|
|
631
|
-
const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
|
|
632
|
-
const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
|
|
633
|
-
const match = CLAUDE_DISPLAY_NAME.exec(base);
|
|
634
|
-
if (!match) return currentClaudeModel(raw);
|
|
635
|
-
return currentClaudeModel(`claude-${match[1].toLowerCase()}-${match[2]}${match[3] ? `-${match[3]}` : ''}${suffix}`);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// The retired models a roster file's text still names, by id or spelled
|
|
639
|
-
// out, each once, so the roster can say they were upgraded.
|
|
640
|
-
function retiredModelsNamed(text) {
|
|
641
|
-
const found = [];
|
|
642
|
-
for (const id of Object.keys(RETIRED_CLAUDE_MODELS)) {
|
|
643
|
-
const [, family, major, minor] = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id) || [];
|
|
644
|
-
if (!family) continue;
|
|
645
|
-
const spelled = new RegExp(`\\b(?:claude[\\s_-]*)?${family}[\\s_-]*${major}[\\s._-]+${minor}(?![\\d.])`, 'i');
|
|
646
|
-
const base = `claude-${family}-${major}-${minor}`;
|
|
647
|
-
if (spelled.test(String(text || '')) && !found.includes(base)) found.push(base);
|
|
648
|
-
}
|
|
649
|
-
return found;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
function normalizeRosterModel(engineId, value) {
|
|
653
|
-
const raw = String(value || '').trim();
|
|
654
|
-
if (!raw) return '';
|
|
655
|
-
if (engineId === 'grok') return normalizeGrokModel(raw);
|
|
656
|
-
if (!CLAUDE_FAMILY_ENGINES.includes(engineId)) return raw;
|
|
657
|
-
// "[1m]" asks the claude cli for the long context window; keep it on
|
|
658
|
-
// whatever name it follows.
|
|
659
|
-
const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
|
|
660
|
-
const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
|
|
661
|
-
const lower = base.toLowerCase();
|
|
662
|
-
if (CLAUDE_MODEL_ALIASES.includes(lower)) return `${lower}${suffix}`;
|
|
663
|
-
const match = CLAUDE_MODEL_SPELLING.exec(lower.replace(/^claude[\s_-]*/, ''));
|
|
664
|
-
if (match) {
|
|
665
|
-
const id = `claude-${match[1]}-${match[2]}${match[3] ? `-${match[3]}` : ''}`;
|
|
666
|
-
return currentClaudeModel(`${id}${suffix}`);
|
|
667
|
-
}
|
|
668
|
-
if (lower.startsWith('claude-')) return currentClaudeModel(`${base}${suffix}`);
|
|
669
|
-
throw new Error(`${engineId} does not know the model "${raw}". use opus, sonnet, haiku, fable, opusplan, default, a version like opus 5.5, sonnet 5, haiku 5.5, or fable 5.1, or a full claude- id; add [1m] for the long context window`);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// A saved pick's model, normalized the same way assign does. null means the
|
|
673
|
-
// saved name is one the claude cli would reject, so the pick cannot run.
|
|
674
|
-
function savedRosterModel(engineId, value) {
|
|
675
|
-
try {
|
|
676
|
-
return normalizeRosterModel(engineId, value);
|
|
677
|
-
} catch {
|
|
678
|
-
return null;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
|
|
683
602
|
// A registry saved before an engine learned a job keeps its old role list;
|
|
684
603
|
// the roster still honors the job the engine can do today, including a job
|
|
685
604
|
// it only takes by roster pick.
|
package/lib/known-commands.js
CHANGED
|
@@ -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,129 @@
|
|
|
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 TAIL_BYTES = 256 * 1024;
|
|
13
|
+
const JOB_WIDTH = 50;
|
|
14
|
+
const STEP_WIDTH = 50;
|
|
15
|
+
|
|
16
|
+
function progressDir() {
|
|
17
|
+
return process.env.ATRIS_PROGRESS_DIR || path.join(os.homedir(), '.atris', 'progress');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readTail(file) {
|
|
21
|
+
const fd = fs.openSync(file, 'r');
|
|
22
|
+
try {
|
|
23
|
+
const size = fs.fstatSync(fd).size;
|
|
24
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
25
|
+
const buf = Buffer.alloc(size - start);
|
|
26
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
27
|
+
const text = buf.toString('utf8');
|
|
28
|
+
// A cut read starts mid-line; drop the partial first line.
|
|
29
|
+
return start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
|
|
30
|
+
} finally {
|
|
31
|
+
fs.closeSync(fd);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseLine(raw) {
|
|
36
|
+
const m = /^(\S+) (\S+) (.*)$/.exec(raw);
|
|
37
|
+
if (!m) return null;
|
|
38
|
+
const at = Date.parse(m[1]);
|
|
39
|
+
if (Number.isNaN(at)) return null;
|
|
40
|
+
return { at, text: m[3] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cut(text, width) {
|
|
44
|
+
const s = String(text || '').replace(/\s+/g, ' ').trim();
|
|
45
|
+
return s.length > width ? `${s.slice(0, width - 3)}...` : s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ago(ms) {
|
|
49
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
50
|
+
if (s < 60) return `${s}s ago`;
|
|
51
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
52
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
53
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The current run is everything from the latest "started:" line on.
|
|
57
|
+
function memberRow(name, text, now) {
|
|
58
|
+
const lines = text.split('\n').map(parseLine).filter(Boolean);
|
|
59
|
+
if (!lines.length) return null;
|
|
60
|
+
let start = 0;
|
|
61
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
62
|
+
if (lines[i].text.startsWith('started:')) { start = i; break; }
|
|
63
|
+
}
|
|
64
|
+
const run = lines.slice(start);
|
|
65
|
+
const startedLine = run[0].text.startsWith('started:') ? run[0] : null;
|
|
66
|
+
const finished = [...run].reverse().find((l) => l.text.startsWith('finished:'));
|
|
67
|
+
const step = [...run].reverse().find((l) => !l.text.startsWith('started:') && !l.text.startsWith('finished:'));
|
|
68
|
+
const last = run[run.length - 1];
|
|
69
|
+
let state;
|
|
70
|
+
if (finished) state = /^finished:\s*ok\b/.test(finished.text) ? 'done ok' : 'done failed';
|
|
71
|
+
else state = now - last.at < QUIET_AFTER_MS ? 'working' : 'quiet';
|
|
72
|
+
return {
|
|
73
|
+
member: name,
|
|
74
|
+
state,
|
|
75
|
+
job: startedLine ? cut(startedLine.text.slice('started:'.length), JOB_WIDTH) : '',
|
|
76
|
+
step: step ? cut(step.text, STEP_WIDTH) : '',
|
|
77
|
+
ago: ago(now - last.at),
|
|
78
|
+
at: last.at,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readBoard({ dir = progressDir(), now = Date.now() } = {}) {
|
|
83
|
+
let files = [];
|
|
84
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.log')); } catch {}
|
|
85
|
+
const rows = [];
|
|
86
|
+
for (const f of files) {
|
|
87
|
+
let text = '';
|
|
88
|
+
try { text = readTail(path.join(dir, f)); } catch { continue; }
|
|
89
|
+
const row = memberRow(f.slice(0, -'.log'.length), text, now);
|
|
90
|
+
if (row) rows.push(row);
|
|
91
|
+
}
|
|
92
|
+
return rows.sort((a, b) => b.at - a.at);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderBoard(rows, dir = progressDir()) {
|
|
96
|
+
if (!rows.length) return `no member progress yet in ${dir}`;
|
|
97
|
+
const w = (key, min) => Math.max(min, ...rows.map((r) => r[key].length));
|
|
98
|
+
const nameW = w('member', 6);
|
|
99
|
+
const stateW = w('state', 5);
|
|
100
|
+
const agoW = w('ago', 3);
|
|
101
|
+
const jobW = Math.max(3, ...rows.map((r) => (r.job || '-').length));
|
|
102
|
+
const pad = (s, n) => s.padEnd(n);
|
|
103
|
+
const line = (a, b, c, d, e) => `${pad(a, nameW)} ${pad(b, stateW)} ${pad(c, agoW)} ${pad(d, jobW)} ${e}`.trimEnd();
|
|
104
|
+
const out = [line('member', 'state', 'ago', 'job', 'last step')];
|
|
105
|
+
for (const r of rows) out.push(line(r.member, r.state, r.ago, r.job || '-', r.step || '-'));
|
|
106
|
+
return out.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function memberBoardCommand(...args) {
|
|
110
|
+
const asJson = args.includes('--json');
|
|
111
|
+
if (asJson) {
|
|
112
|
+
console.log(JSON.stringify({ ok: true, dir: progressDir(), members: readBoard() }, null, 2));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const draw = () => renderBoard(readBoard());
|
|
116
|
+
if (!args.includes('--watch')) {
|
|
117
|
+
console.log(draw());
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const redraw = () => process.stdout.write(`\x1b[2J\x1b[H${draw()}\n\nredraws every 3s, ctrl-c to stop\n`);
|
|
121
|
+
redraw();
|
|
122
|
+
// The member route exits once this settles, so only settle on ctrl-c.
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const timer = setInterval(redraw, 3000);
|
|
125
|
+
process.once('SIGINT', () => { clearInterval(timer); resolve(); });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { readBoard, renderBoard, memberRow, ago, memberBoardCommand };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Live progress for a working member: one plain line per tool step, printed to
|
|
4
|
+
// stderr and appended to <dir>/<member>.log so another shell can tail it.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
function realish(p) {
|
|
11
|
+
try { return fs.realpathSync(p); } catch {}
|
|
12
|
+
try { return path.join(fs.realpathSync(path.dirname(p)), path.basename(p)); } catch {}
|
|
13
|
+
return p;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function insideRel(cwd, p) {
|
|
17
|
+
const rel = path.relative(cwd, p);
|
|
18
|
+
return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Relative to cwd when inside it; symlinked roots (macOS /var vs /private/var)
|
|
22
|
+
// still count as inside.
|
|
23
|
+
function relativeTo(cwd, file) {
|
|
24
|
+
const p = String(file || '');
|
|
25
|
+
if (!p || !cwd || !path.isAbsolute(p)) return p;
|
|
26
|
+
return insideRel(cwd, p) || insideRel(realish(cwd), realish(p)) || p;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatToolStep(block, cwd) {
|
|
30
|
+
if (!block || block.type !== 'tool_use') return null;
|
|
31
|
+
const name = String(block.name || 'tool');
|
|
32
|
+
const input = block.input || {};
|
|
33
|
+
switch (name) {
|
|
34
|
+
case 'Read':
|
|
35
|
+
return `reading ${relativeTo(cwd, input.file_path)}`;
|
|
36
|
+
case 'Edit':
|
|
37
|
+
case 'MultiEdit':
|
|
38
|
+
case 'Write':
|
|
39
|
+
return `editing ${relativeTo(cwd, input.file_path)}`;
|
|
40
|
+
case 'Bash': {
|
|
41
|
+
const first = String(input.command || '').split('\n')[0].trim();
|
|
42
|
+
return `running: ${first.slice(0, 70)}`;
|
|
43
|
+
}
|
|
44
|
+
case 'Grep':
|
|
45
|
+
return `searching for ${input.pattern || ''}`;
|
|
46
|
+
case 'Glob':
|
|
47
|
+
return `finding ${input.pattern || ''}`;
|
|
48
|
+
default:
|
|
49
|
+
return name.toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function progressDir() {
|
|
54
|
+
return process.env.ATRIS_PROGRESS_DIR || path.join(os.homedir(), '.atris', 'progress');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeMember(member) {
|
|
58
|
+
return String(member || 'mission').replace(/[^A-Za-z0-9._-]/g, '_') || 'mission';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hhmm(date = new Date()) {
|
|
62
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function appendProgress(member, line, missionId) {
|
|
66
|
+
// Test runs never write into the real home folder unless they name a dir.
|
|
67
|
+
if (!process.env.ATRIS_PROGRESS_DIR && process.env.NODE_TEST_CONTEXT) return;
|
|
68
|
+
try {
|
|
69
|
+
const dir = progressDir();
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
const entry = `${new Date().toISOString()} ${missionId || '-'} ${line}\n`;
|
|
72
|
+
fs.appendFileSync(path.join(dir, `${safeMember(member)}.log`), entry, 'utf8');
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Print one step for the operator and keep it in the member's progress log.
|
|
77
|
+
function reportProgress(member, line, missionId) {
|
|
78
|
+
try { process.stderr.write(` ${member || 'mission'} ${hhmm()} ${line}\n`); } catch {}
|
|
79
|
+
appendProgress(member, line, missionId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { formatToolStep, appendProgress, reportProgress };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Friendly model names to the ids each engine's cli accepts. This is the ONE
|
|
4
|
+
// table of spellings: engine-registry checks roster picks with it and
|
|
5
|
+
// runner-command normalizes launch models with it. "opus 5.5" is
|
|
6
|
+
// claude-opus-5-5, "grok 4.7 fast" is grok-4.7-build-fast. Kept free of
|
|
7
|
+
// requires so both files can load it without a circular dependency.
|
|
8
|
+
|
|
9
|
+
// Claude-family engines run through the claude CLI, which rejects loose
|
|
10
|
+
// spellings like "opus-5.5". Assign saves the id the CLI accepts, and every
|
|
11
|
+
// read normalizes again, so older saved picks and hand edits work too.
|
|
12
|
+
const CLAUDE_FAMILY_ENGINES = Object.freeze(['claude', 'fable', 'haiku']);
|
|
13
|
+
const CLAUDE_MODEL_ALIASES = Object.freeze(['opus', 'sonnet', 'haiku', 'fable', 'opusplan', 'default']);
|
|
14
|
+
// A retired model runs as its replacement, so a saved pick keeps working
|
|
15
|
+
// after the upgrade. Retiring the next one is one line here.
|
|
16
|
+
const RETIRED_CLAUDE_MODELS = Object.freeze({
|
|
17
|
+
'claude-fable-5': 'claude-fable-5-1',
|
|
18
|
+
'claude-haiku-4-5': 'claude-haiku-5-5',
|
|
19
|
+
'claude-haiku-4-5-20251001': 'claude-haiku-5-5',
|
|
20
|
+
});
|
|
21
|
+
const CLAUDE_MODEL_SPELLING = /^(opus|sonnet|haiku|fable)[\s_-]*(\d+)(?:[\s._-]+(\d+))?$/;
|
|
22
|
+
const CLAUDE_LONG_CONTEXT = /\s*\[1m\]$/;
|
|
23
|
+
|
|
24
|
+
// Grok takes friendly names too: "grok 4.7 fast" is grok-4.7-build-fast and
|
|
25
|
+
// "grok 4.7" is grok-4.7. Any full grok- id passes through as typed.
|
|
26
|
+
const GROK_MODEL_SPELLING = /^grok[\s_-]*(\d+(?:\.\d+)?)(?:[\s_-]+(?:build[\s_-]+)?(fast))?$/;
|
|
27
|
+
|
|
28
|
+
function normalizeGrokModel(raw) {
|
|
29
|
+
const match = GROK_MODEL_SPELLING.exec(raw.toLowerCase());
|
|
30
|
+
if (match) return `grok-${match[1]}${match[2] ? '-build-fast' : ''}`;
|
|
31
|
+
if (/^grok-[a-z0-9][a-z0-9._-]*$/i.test(raw)) return raw;
|
|
32
|
+
throw new Error(`grok does not know the model "${raw}". use grok 4.7 fast, grok 4.7, or a full grok- id`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The model to run for a claude id: its replacement when it is retired,
|
|
36
|
+
// else the id as given. Keeps a "[1m]" tail.
|
|
37
|
+
function currentClaudeModel(value) {
|
|
38
|
+
const raw = String(value || '').trim();
|
|
39
|
+
const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
|
|
40
|
+
const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
|
|
41
|
+
const next = RETIRED_CLAUDE_MODELS[base.toLowerCase()];
|
|
42
|
+
return next ? `${next}${suffix}` : raw;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The id the claude cli accepts for a display name typed with a space:
|
|
46
|
+
// "opus 5.5" is claude-opus-5-5, "fable 5.1" is claude-fable-5-1. A full id,
|
|
47
|
+
// a bare alias ("opus"), a "[1m]" tail and anything else pass through as given
|
|
48
|
+
// (a retired id still becomes its replacement).
|
|
49
|
+
const CLAUDE_DISPLAY_NAME = /^(opus|sonnet|haiku|fable)\s+(\d+)(?:\.(\d+))?$/i;
|
|
50
|
+
|
|
51
|
+
function claudeModelId(value) {
|
|
52
|
+
const raw = String(value || '').trim();
|
|
53
|
+
const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
|
|
54
|
+
const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
|
|
55
|
+
const match = CLAUDE_DISPLAY_NAME.exec(base);
|
|
56
|
+
if (!match) return currentClaudeModel(raw);
|
|
57
|
+
return currentClaudeModel(`claude-${match[1].toLowerCase()}-${match[2]}${match[3] ? `-${match[3]}` : ''}${suffix}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The retired models a roster file's text still names, by id or spelled
|
|
61
|
+
// out, each once, so the roster can say they were upgraded.
|
|
62
|
+
function retiredModelsNamed(text) {
|
|
63
|
+
const found = [];
|
|
64
|
+
for (const id of Object.keys(RETIRED_CLAUDE_MODELS)) {
|
|
65
|
+
const [, family, major, minor] = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id) || [];
|
|
66
|
+
if (!family) continue;
|
|
67
|
+
const spelled = new RegExp(`\\b(?:claude[\\s_-]*)?${family}[\\s_-]*${major}[\\s._-]+${minor}(?![\\d.])`, 'i');
|
|
68
|
+
const base = `claude-${family}-${major}-${minor}`;
|
|
69
|
+
if (spelled.test(String(text || '')) && !found.includes(base)) found.push(base);
|
|
70
|
+
}
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeRosterModel(engineId, value) {
|
|
75
|
+
const raw = String(value || '').trim();
|
|
76
|
+
if (!raw) return '';
|
|
77
|
+
if (engineId === 'grok') return normalizeGrokModel(raw);
|
|
78
|
+
if (!CLAUDE_FAMILY_ENGINES.includes(engineId)) return raw;
|
|
79
|
+
// "[1m]" asks the claude cli for the long context window; keep it on
|
|
80
|
+
// whatever name it follows.
|
|
81
|
+
const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
|
|
82
|
+
const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
|
|
83
|
+
const lower = base.toLowerCase();
|
|
84
|
+
if (CLAUDE_MODEL_ALIASES.includes(lower)) return `${lower}${suffix}`;
|
|
85
|
+
const match = CLAUDE_MODEL_SPELLING.exec(lower.replace(/^claude[\s_-]*/, ''));
|
|
86
|
+
if (match) {
|
|
87
|
+
const id = `claude-${match[1]}-${match[2]}${match[3] ? `-${match[3]}` : ''}`;
|
|
88
|
+
return currentClaudeModel(`${id}${suffix}`);
|
|
89
|
+
}
|
|
90
|
+
if (lower.startsWith('claude-')) return currentClaudeModel(`${base}${suffix}`);
|
|
91
|
+
throw new Error(`${engineId} does not know the model "${raw}". use opus, sonnet, haiku, fable, opusplan, default, a version like opus 5.5, sonnet 5, haiku 5.5, or fable 5.1, or a full claude- id; add [1m] for the long context window`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A saved pick's model, normalized the same way assign does. null means the
|
|
95
|
+
// saved name is one the claude cli would reject, so the pick cannot run.
|
|
96
|
+
function savedRosterModel(engineId, value) {
|
|
97
|
+
try {
|
|
98
|
+
return normalizeRosterModel(engineId, value);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
CLAUDE_FAMILY_ENGINES,
|
|
106
|
+
CLAUDE_MODEL_ALIASES,
|
|
107
|
+
RETIRED_CLAUDE_MODELS,
|
|
108
|
+
CLAUDE_MODEL_SPELLING,
|
|
109
|
+
CLAUDE_LONG_CONTEXT,
|
|
110
|
+
GROK_MODEL_SPELLING,
|
|
111
|
+
currentClaudeModel,
|
|
112
|
+
claudeModelId,
|
|
113
|
+
retiredModelsNamed,
|
|
114
|
+
normalizeRosterModel,
|
|
115
|
+
savedRosterModel,
|
|
116
|
+
};
|
package/lib/runner-command.js
CHANGED
|
@@ -16,19 +16,20 @@ const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
|
|
|
16
16
|
const EFFORT_WORDS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
17
17
|
const CLAUDE_EFFORTS = EFFORT_WORDS;
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
'opus 4.8': 'claude-opus-4-8',
|
|
25
|
-
'opus-4.8': 'claude-opus-4-8',
|
|
26
|
-
});
|
|
19
|
+
// The one model-name table lives in model-names.js, shared with
|
|
20
|
+
// engine-registry. Friendly names become the ids the claude cli accepts;
|
|
21
|
+
// anything it does not know passes through unchanged (runners like glm and
|
|
22
|
+
// swe name their own models here, so this never throws).
|
|
23
|
+
const { claudeModelId, currentClaudeModel, normalizeRosterModel } = require('./model-names');
|
|
27
24
|
|
|
28
25
|
function normalizeClaudeModelName(value) {
|
|
29
26
|
const trimmed = String(value || '').trim();
|
|
30
27
|
if (!trimmed) return '';
|
|
31
|
-
|
|
28
|
+
try {
|
|
29
|
+
return normalizeRosterModel('claude', trimmed);
|
|
30
|
+
} catch {
|
|
31
|
+
return trimmed;
|
|
32
|
+
}
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
// Canonical runner profiles. Each entry is the config an operator would pick
|
|
@@ -246,7 +247,7 @@ function ownModelsOnlyEngineForBin(bin) {
|
|
|
246
247
|
// claude env pin) and rides its own default instead.
|
|
247
248
|
function runnerModelFor(engineId, model) {
|
|
248
249
|
// Required here, not at the top: engine-registry requires this file.
|
|
249
|
-
const { isClaudeFamilyModel
|
|
250
|
+
const { isClaudeFamilyModel } = require('./engine-registry');
|
|
250
251
|
const name = String(engineId || '').trim().toLowerCase();
|
|
251
252
|
// A display name like "opus 5.5" only becomes an id for the claude cli (or
|
|
252
253
|
// an unnamed engine). Any other engine's model name is handed over as is.
|