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 +5 -0
- package/commands/member.js +119 -91
- package/commands/scoreboard.js +151 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-board.js +263 -0
- package/lib/member-progress.js +16 -4
- 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, --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))
|
package/commands/member.js
CHANGED
|
@@ -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
|
|
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
|
-
});
|
|
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
|
|
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
|
|
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
|
|
10046
|
-
|
|
10047
|
-
const
|
|
10048
|
-
|
|
10049
|
-
?
|
|
10050
|
-
|
|
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
|
-
|
|
10054
|
-
|
|
10055
|
-
const passed =
|
|
10056
|
-
const
|
|
10057
|
-
const rawNextPush =
|
|
10058
|
-
|
|
10059
|
-
|
|
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
|
|
10064
|
-
nextPush
|
|
10065
|
-
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(
|
|
10071
|
-
at_ms:
|
|
10072
|
-
age: ageLabelFromMs(
|
|
10095
|
+
at: new Date(atMs).toISOString(),
|
|
10096
|
+
at_ms: atMs,
|
|
10097
|
+
age: ageLabelFromMs(atMs),
|
|
10073
10098
|
summary: cleanSummary,
|
|
10074
|
-
|
|
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:
|
|
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
|
-
? ['
|
|
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
|
|
10832
|
-
|
|
10833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
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,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 };
|
package/lib/member-progress.js
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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 };
|