atris 3.68.6 → 3.68.8
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 +1 -1
- package/commands/member.js +51 -0
- package/commands/scoreboard.js +24 -5
- package/commands/sync.js +15 -0
- package/lib/member-board.js +142 -8
- package/lib/member-export.js +240 -0
- package/lib/member-progress.js +16 -4
- package/package.json +1 -1
package/bin/atris.js
CHANGED
|
@@ -630,7 +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
|
+
console.log(' scoreboard - Today\'s github contributions vs your best day, streak, repo commits (--watch, --json)');
|
|
634
634
|
console.log(' brain - Compile MAP/TODO/wiki/state into a loadable agent brain');
|
|
635
635
|
console.log(' lesson - atris lesson add <slug> <pass|fail> "<text>" (mine/sweep/resolve/revert/ledger; --detector validates the falsifier at write time)');
|
|
636
636
|
console.log(' taste - Record the operator\'s keep, kill, and more creative verdicts');
|
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)
|
|
@@ -10879,6 +10904,29 @@ async function memberRoom(...args) {
|
|
|
10879
10904
|
return room;
|
|
10880
10905
|
}
|
|
10881
10906
|
|
|
10907
|
+
// --- EXPORT subcommand ---
|
|
10908
|
+
|
|
10909
|
+
function memberExport(...args) {
|
|
10910
|
+
const { exportMembers, summaryLine } = require('../lib/member-export');
|
|
10911
|
+
const dryRun = args.includes('--dry-run');
|
|
10912
|
+
const root = process.cwd();
|
|
10913
|
+
if (!fs.existsSync(path.join(root, 'atris', 'team'))) {
|
|
10914
|
+
console.error('No atris/team folder here, so there are no members to export.');
|
|
10915
|
+
process.exitCode = 1;
|
|
10916
|
+
return null;
|
|
10917
|
+
}
|
|
10918
|
+
const result = exportMembers(root, { dryRun });
|
|
10919
|
+
if (args.includes('--json')) {
|
|
10920
|
+
console.log(JSON.stringify({ dry_run: dryRun, ...result }, null, 2));
|
|
10921
|
+
return result;
|
|
10922
|
+
}
|
|
10923
|
+
for (const file of result.written) console.log(`${dryRun ? 'Would write' : '✓ Wrote'} ${file}`);
|
|
10924
|
+
for (const file of result.removed) console.log(`${dryRun ? 'Would remove' : '✓ Removed'} ${file}`);
|
|
10925
|
+
for (const file of result.kept) console.log(`Left alone ${file} (hand-written, not made by export)`);
|
|
10926
|
+
console.log(summaryLine(result, { dryRun }));
|
|
10927
|
+
return result;
|
|
10928
|
+
}
|
|
10929
|
+
|
|
10882
10930
|
async function memberCommand(subcommand, ...args) {
|
|
10883
10931
|
// Subcommands that take a member name as args[0] otherwise treat `--help` as
|
|
10884
10932
|
// a name and error with "Member '--help' not found". `create`/`new` handle
|
|
@@ -10988,6 +11036,8 @@ async function memberCommand(subcommand, ...args) {
|
|
|
10988
11036
|
return memberObjectiveGeneratorCommand(args[0], ...args.slice(1));
|
|
10989
11037
|
case 'generalist':
|
|
10990
11038
|
return memberGeneralistCommand(args[0], ...args.slice(1));
|
|
11039
|
+
case 'export':
|
|
11040
|
+
return memberExport(...args);
|
|
10991
11041
|
case 'archive':
|
|
10992
11042
|
return memberArchive(args[0]);
|
|
10993
11043
|
case 'purge-archived':
|
|
@@ -11022,6 +11072,7 @@ async function memberCommand(subcommand, ...args) {
|
|
|
11022
11072
|
console.log(' install <slug> [--update] Install missing files, or refresh template skills');
|
|
11023
11073
|
console.log(' chat <name> "..." Send a message to this member\'s desk in Atris Desktop');
|
|
11024
11074
|
console.log(' list [--json] Show all team members');
|
|
11075
|
+
console.log(' export [--dry-run] Write each member as a subagent for Claude Code, Cursor, and Codex');
|
|
11025
11076
|
console.log(' activate <name> Symlink member skills, show context and permissions');
|
|
11026
11077
|
console.log(' upgrade <name> Convert flat file (name.md) to directory format');
|
|
11027
11078
|
console.log(' push <name> Push a local team member to the cloud');
|
package/commands/scoreboard.js
CHANGED
|
@@ -48,13 +48,21 @@ function bar(count, target) {
|
|
|
48
48
|
return `[${'#'.repeat(filled)}${'-'.repeat(BAR_WIDTH - filled)}]`;
|
|
49
49
|
}
|
|
50
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.
|
|
51
54
|
function repoCommits(cwd = process.cwd(), now = new Date()) {
|
|
52
55
|
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
53
56
|
try {
|
|
54
|
-
const out = execFileSync('git', ['log', `--since=${midnight.toISOString()}`, '--format=%B%x1e'], {
|
|
57
|
+
const out = execFileSync('git', ['log', '--exclude=refs/stash', '--all', `--since=${midnight.toISOString()}`, '--format=%H%x1f%B%x1e'], {
|
|
55
58
|
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
56
59
|
});
|
|
57
|
-
const
|
|
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()];
|
|
58
66
|
const members = bodies.filter((b) => b.includes(MEMBER_TRAILER)).length;
|
|
59
67
|
return { total: bodies.length, you: bodies.length - members, members };
|
|
60
68
|
} catch {
|
|
@@ -90,14 +98,21 @@ function cheers(prev, next) {
|
|
|
90
98
|
|
|
91
99
|
function snapshot() {
|
|
92
100
|
const s = score(fetchDays());
|
|
93
|
-
|
|
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 };
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
async function scoreboardCommand(args = []) {
|
|
97
111
|
if (args.includes('--help') || args.includes('-h')) {
|
|
98
|
-
console.log('usage: atris scoreboard [--watch]');
|
|
112
|
+
console.log('usage: atris scoreboard [--watch | --json]');
|
|
99
113
|
console.log(' today\'s github contributions, your best day this year, your streak, and this repo\'s commits');
|
|
100
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');
|
|
101
116
|
return 0;
|
|
102
117
|
}
|
|
103
118
|
let snap;
|
|
@@ -107,6 +122,10 @@ async function scoreboardCommand(args = []) {
|
|
|
107
122
|
console.error('could not read your github contributions. check that gh is installed and logged in (gh auth status).');
|
|
108
123
|
return 1;
|
|
109
124
|
}
|
|
125
|
+
if (args.includes('--json')) {
|
|
126
|
+
console.log(JSON.stringify(toJson(snap.s, snap.commits), null, 2));
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
110
129
|
console.log(snap.text);
|
|
111
130
|
if (!args.includes('--watch')) return 0;
|
|
112
131
|
|
|
@@ -129,4 +148,4 @@ async function scoreboardCommand(args = []) {
|
|
|
129
148
|
return 0;
|
|
130
149
|
}
|
|
131
150
|
|
|
132
|
-
module.exports = { scoreboardCommand, score, render, cheers, bar, localDate };
|
|
151
|
+
module.exports = { scoreboardCommand, score, render, toJson, cheers, bar, localDate };
|
package/commands/sync.js
CHANGED
|
@@ -437,6 +437,18 @@ function syncBusinessCanonical(targetRoot, bizMeta, options = {}) {
|
|
|
437
437
|
});
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
// Team members follow every written update, business or legacy, into Claude
|
|
441
|
+
// Code, Cursor, and Codex as subagents. An export failure never fails the update.
|
|
442
|
+
function exportTeamAfterUpdate(root, { dryRun = false } = {}) {
|
|
443
|
+
if (dryRun || !fs.existsSync(path.join(root, 'atris', 'team'))) return;
|
|
444
|
+
try {
|
|
445
|
+
const { exportMembers, summaryLine } = require('../lib/member-export');
|
|
446
|
+
console.log(`✓ Team: ${summaryLine(exportMembers(root))}`);
|
|
447
|
+
} catch (err) {
|
|
448
|
+
console.log(`Team export skipped: ${err.message || err}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
440
452
|
function syncAtris(options = {}) {
|
|
441
453
|
const explicitDryRun = options.dryRun != null ? options.dryRun : process.argv.includes('--dry-run');
|
|
442
454
|
const yes = options.yes != null
|
|
@@ -455,6 +467,7 @@ function syncAtris(options = {}) {
|
|
|
455
467
|
dryRun,
|
|
456
468
|
force,
|
|
457
469
|
});
|
|
470
|
+
exportTeamAfterUpdate(process.cwd(), { dryRun });
|
|
458
471
|
if (!yes) {
|
|
459
472
|
console.log('\nPlan only. Re-run with --yes to write these files.');
|
|
460
473
|
if (!explicitDryRun && isNonInteractive()) process.exit(2);
|
|
@@ -695,6 +708,8 @@ Key behaviors:
|
|
|
695
708
|
console.log(`\n✓ Updated ${updated} file(s), ${skipped} unchanged`);
|
|
696
709
|
console.log('\nRun your AI agent again to use the latest specs and agent templates.');
|
|
697
710
|
}
|
|
711
|
+
|
|
712
|
+
exportTeamAfterUpdate(process.cwd(), { dryRun });
|
|
698
713
|
}
|
|
699
714
|
|
|
700
715
|
/**
|
package/lib/member-board.js
CHANGED
|
@@ -9,6 +9,7 @@ const os = require('os');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
|
|
11
11
|
const QUIET_AFTER_MS = 2 * 60 * 1000;
|
|
12
|
+
const HIDE_DONE_AFTER_MS = 60 * 60 * 1000;
|
|
12
13
|
const TAIL_BYTES = 256 * 1024;
|
|
13
14
|
const JOB_WIDTH = 50;
|
|
14
15
|
const STEP_WIDTH = 50;
|
|
@@ -53,7 +54,13 @@ function ago(ms) {
|
|
|
53
54
|
return `${Math.floor(s / 86400)}d ago`;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
function escapeRe(s) {
|
|
58
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
59
|
+
}
|
|
60
|
+
|
|
56
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.
|
|
57
64
|
function memberRow(name, text, now) {
|
|
58
65
|
const lines = text.split('\n').map(parseLine).filter(Boolean);
|
|
59
66
|
if (!lines.length) return null;
|
|
@@ -64,7 +71,10 @@ function memberRow(name, text, now) {
|
|
|
64
71
|
const run = lines.slice(start);
|
|
65
72
|
const startedLine = run[0].text.startsWith('started:') ? run[0] : null;
|
|
66
73
|
const finished = [...run].reverse().find((l) => l.text.startsWith('finished:'));
|
|
67
|
-
const
|
|
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];
|
|
68
78
|
const last = run[run.length - 1];
|
|
69
79
|
let state;
|
|
70
80
|
if (finished) state = /^finished:\s*ok\b/.test(finished.text) ? 'done ok' : 'done failed';
|
|
@@ -76,10 +86,12 @@ function memberRow(name, text, now) {
|
|
|
76
86
|
step: step ? cut(step.text, STEP_WIDTH) : '',
|
|
77
87
|
ago: ago(now - last.at),
|
|
78
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,
|
|
79
91
|
};
|
|
80
92
|
}
|
|
81
93
|
|
|
82
|
-
function readBoard({ dir = progressDir(), now = Date.now() } = {}) {
|
|
94
|
+
function readBoard({ dir = progressDir(), now = Date.now(), all = false } = {}) {
|
|
83
95
|
let files = [];
|
|
84
96
|
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.log')); } catch {}
|
|
85
97
|
const rows = [];
|
|
@@ -89,11 +101,12 @@ function readBoard({ dir = progressDir(), now = Date.now() } = {}) {
|
|
|
89
101
|
const row = memberRow(f.slice(0, -'.log'.length), text, now);
|
|
90
102
|
if (row) rows.push(row);
|
|
91
103
|
}
|
|
92
|
-
return rows.sort((a, b) => b.at - a.at);
|
|
104
|
+
return rows.filter((r) => all || !r.idle).sort((a, b) => b.at - a.at);
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
function renderBoard(rows, dir = progressDir()) {
|
|
96
|
-
|
|
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}`;
|
|
97
110
|
const w = (key, min) => Math.max(min, ...rows.map((r) => r[key].length));
|
|
98
111
|
const nameW = w('member', 6);
|
|
99
112
|
const stateW = w('state', 5);
|
|
@@ -103,16 +116,137 @@ function renderBoard(rows, dir = progressDir()) {
|
|
|
103
116
|
const line = (a, b, c, d, e) => `${pad(a, nameW)} ${pad(b, stateW)} ${pad(c, agoW)} ${pad(d, jobW)} ${e}`.trimEnd();
|
|
104
117
|
const out = [line('member', 'state', 'ago', 'job', 'last step')];
|
|
105
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);
|
|
106
120
|
return out.join('\n');
|
|
107
121
|
}
|
|
108
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
|
+
|
|
109
226
|
function memberBoardCommand(...args) {
|
|
110
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
|
+
}
|
|
111
241
|
if (asJson) {
|
|
112
|
-
console.log(JSON.stringify({ ok: true, dir: progressDir(), members: readBoard() }, null, 2));
|
|
242
|
+
console.log(JSON.stringify({ ok: true, dir: progressDir(), members: readBoard({ all }) }, null, 2));
|
|
113
243
|
return;
|
|
114
244
|
}
|
|
115
|
-
const draw = () =>
|
|
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
|
+
};
|
|
116
250
|
if (!args.includes('--watch')) {
|
|
117
251
|
console.log(draw());
|
|
118
252
|
return;
|
|
@@ -126,4 +260,4 @@ function memberBoardCommand(...args) {
|
|
|
126
260
|
});
|
|
127
261
|
}
|
|
128
262
|
|
|
129
|
-
module.exports = { readBoard, renderBoard, memberRow, ago, memberBoardCommand };
|
|
263
|
+
module.exports = { readBoard, renderBoard, memberRow, ago, progressEvent, followBoard, memberBoardCommand };
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `atris member export`: each active atris/team/<member>/MEMBER.md becomes a
|
|
4
|
+
// native subagent file for the coding tools that read one.
|
|
5
|
+
//
|
|
6
|
+
// claude .claude/agents/<name>.md markdown, YAML frontmatter (name, description, model)
|
|
7
|
+
// cursor .cursor/agents/<name>.md same markdown shape; cursor-agent globs **/.cursor/agents/**/*.md
|
|
8
|
+
// codex .codex/agents/<name>.toml agent role file (name, description, developer_instructions);
|
|
9
|
+
// codex 0.162 discovers roles in .codex/agents
|
|
10
|
+
//
|
|
11
|
+
// Every file carries GENERATED_MARK. Export only overwrites or removes files
|
|
12
|
+
// that carry it, so a hand-written agent with the same name is left alone.
|
|
13
|
+
// Parked members are skipped, and their old generated files are removed.
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { isParkedText } = require('./member-park');
|
|
18
|
+
|
|
19
|
+
const GENERATED_MARK = 'generated by atris member export';
|
|
20
|
+
const TARGETS = ['claude', 'cursor', 'codex'];
|
|
21
|
+
const SKIP_DIRS = new Set(['_template', '_archive', '_archived']);
|
|
22
|
+
|
|
23
|
+
function frontmatterOf(text) {
|
|
24
|
+
const match = String(text || '').replace(/^/, '').match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
25
|
+
if (!match) return { fields: {}, body: String(text || '') };
|
|
26
|
+
const fields = {};
|
|
27
|
+
let listKey = null;
|
|
28
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
29
|
+
const item = line.match(/^\s+-\s+(.+)$/);
|
|
30
|
+
if (item && listKey) {
|
|
31
|
+
fields[listKey].push(item[1].trim().replace(/^["']|["']$/g, ''));
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const kv = line.match(/^([a-zA-Z_-]+):\s*(.*)$/);
|
|
35
|
+
if (!kv) continue;
|
|
36
|
+
const value = kv[2].replace(/\s+#.*$/, '').trim();
|
|
37
|
+
if (value === '') {
|
|
38
|
+
fields[kv[1]] = [];
|
|
39
|
+
listKey = kv[1];
|
|
40
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
41
|
+
fields[kv[1]] = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
42
|
+
listKey = null;
|
|
43
|
+
} else {
|
|
44
|
+
fields[kv[1]] = value.replace(/^["']|["']$/g, '');
|
|
45
|
+
listKey = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { fields, body: String(text).replace(/^/, '').slice(match[0].length) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scalar(value) {
|
|
52
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function listOf(value) {
|
|
56
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string' && v.trim()) : [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Skills named in frontmatter plus any skills/<name>/SKILL.md on disk.
|
|
60
|
+
function memberSkills(dir, fields) {
|
|
61
|
+
const names = new Set(listOf(fields.skills));
|
|
62
|
+
const skillsDir = path.join(dir, 'skills');
|
|
63
|
+
try {
|
|
64
|
+
for (const entry of fs.readdirSync(skillsDir)) {
|
|
65
|
+
if (fs.existsSync(path.join(skillsDir, entry, 'SKILL.md'))) names.add(entry);
|
|
66
|
+
}
|
|
67
|
+
} catch {}
|
|
68
|
+
return [...names].sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readMembers(root) {
|
|
72
|
+
const teamDir = path.join(root, 'atris', 'team');
|
|
73
|
+
let entries = [];
|
|
74
|
+
try {
|
|
75
|
+
entries = fs.readdirSync(teamDir).sort();
|
|
76
|
+
} catch {
|
|
77
|
+
return { members: [], parked: [] };
|
|
78
|
+
}
|
|
79
|
+
const members = [];
|
|
80
|
+
const parked = [];
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
83
|
+
const dir = path.join(teamDir, entry);
|
|
84
|
+
const file = path.join(dir, 'MEMBER.md');
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
text = fs.readFileSync(file, 'utf8');
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (isParkedText(text)) {
|
|
92
|
+
parked.push(entry);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const { fields, body } = frontmatterOf(text);
|
|
96
|
+
members.push({
|
|
97
|
+
slug: entry,
|
|
98
|
+
role: scalar(fields.role),
|
|
99
|
+
description: scalar(fields.description),
|
|
100
|
+
model: scalar(fields.model),
|
|
101
|
+
skills: memberSkills(dir, fields),
|
|
102
|
+
body: body.trim(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return { members, parked };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Claude Code wants lowercase letters, digits, and hyphens; codex allows
|
|
109
|
+
// letters, digits, spaces, hyphens, and underscores. One safe form fits both.
|
|
110
|
+
function agentName(slug) {
|
|
111
|
+
return String(slug).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'member';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Written for the tool's router: what the member does, then when to hand it work.
|
|
115
|
+
function agentDescription(member) {
|
|
116
|
+
const what = member.description.replace(/\s+/g, ' ').trim();
|
|
117
|
+
const role = member.role || member.slug;
|
|
118
|
+
const lead = what ? (/[.!?]$/.test(what) ? what : `${what}.`) : `The ${role} on this project's Atris team.`;
|
|
119
|
+
return `${lead} Use this agent when a task fits the ${role} role on this project's Atris team.`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function instructions(member) {
|
|
123
|
+
const lines = [
|
|
124
|
+
`You are ${member.slug}, the ${member.role || member.slug} on this project's Atris team.`,
|
|
125
|
+
`Your full card is atris/team/${member.slug}/MEMBER.md; read it when this summary is not enough.`,
|
|
126
|
+
'',
|
|
127
|
+
];
|
|
128
|
+
if (member.body) lines.push(member.body, '');
|
|
129
|
+
if (member.skills.length) {
|
|
130
|
+
lines.push('## Skills', '');
|
|
131
|
+
for (const skill of member.skills) {
|
|
132
|
+
lines.push(`- ${skill}: atris/team/${member.slug}/skills/${skill}/SKILL.md`);
|
|
133
|
+
}
|
|
134
|
+
lines.push('');
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n').trimEnd() + '\n';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function markdownAgent(member, { withModel }) {
|
|
140
|
+
const source = `atris/team/${member.slug}/MEMBER.md`;
|
|
141
|
+
const front = [
|
|
142
|
+
'---',
|
|
143
|
+
`# ${GENERATED_MARK} from ${source}. edit MEMBER.md; this file gets overwritten.`,
|
|
144
|
+
`name: ${agentName(member.slug)}`,
|
|
145
|
+
`description: ${JSON.stringify(agentDescription(member))}`,
|
|
146
|
+
];
|
|
147
|
+
if (withModel && member.model) front.push(`model: ${JSON.stringify(member.model)}`);
|
|
148
|
+
front.push('---', '', '');
|
|
149
|
+
return front.join('\n') + instructions(member);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// TOML basic strings take the same escapes JSON.stringify emits.
|
|
153
|
+
function codexAgent(member) {
|
|
154
|
+
const source = `atris/team/${member.slug}/MEMBER.md`;
|
|
155
|
+
return [
|
|
156
|
+
`# ${GENERATED_MARK} from ${source}. edit MEMBER.md; this file gets overwritten.`,
|
|
157
|
+
`name = ${JSON.stringify(agentName(member.slug))}`,
|
|
158
|
+
`description = ${JSON.stringify(agentDescription(member))}`,
|
|
159
|
+
`developer_instructions = ${JSON.stringify(instructions(member))}`,
|
|
160
|
+
'',
|
|
161
|
+
].join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const FORMATS = {
|
|
165
|
+
claude: { dir: ['.claude', 'agents'], ext: '.md', render: (m) => markdownAgent(m, { withModel: true }) },
|
|
166
|
+
cursor: { dir: ['.cursor', 'agents'], ext: '.md', render: (m) => markdownAgent(m, { withModel: false }) },
|
|
167
|
+
codex: { dir: ['.codex', 'agents'], ext: '.toml', render: codexAgent },
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function isGenerated(file) {
|
|
171
|
+
try {
|
|
172
|
+
return fs.readFileSync(file, 'utf8').slice(0, 400).includes(GENERATED_MARK);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function exportMembers(root, { targets = TARGETS, dryRun = false } = {}) {
|
|
179
|
+
const { members, parked } = readMembers(root);
|
|
180
|
+
const result = { members: members.map((m) => m.slug), parked, written: [], unchanged: [], removed: [], kept: [] };
|
|
181
|
+
if (!fs.existsSync(path.join(root, 'atris', 'team'))) return result;
|
|
182
|
+
|
|
183
|
+
for (const target of targets) {
|
|
184
|
+
const format = FORMATS[target];
|
|
185
|
+
if (!format) continue;
|
|
186
|
+
const dir = path.join(root, ...format.dir);
|
|
187
|
+
const wanted = new Set();
|
|
188
|
+
for (const member of members) {
|
|
189
|
+
const file = path.join(dir, agentName(member.slug) + format.ext);
|
|
190
|
+
const rel = path.relative(root, file);
|
|
191
|
+
wanted.add(file);
|
|
192
|
+
const next = format.render(member);
|
|
193
|
+
if (fs.existsSync(file)) {
|
|
194
|
+
if (!isGenerated(file)) {
|
|
195
|
+
result.kept.push(rel);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (fs.readFileSync(file, 'utf8') === next) {
|
|
199
|
+
result.unchanged.push(rel);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!dryRun) {
|
|
204
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
205
|
+
fs.writeFileSync(file, next, 'utf8');
|
|
206
|
+
}
|
|
207
|
+
result.written.push(rel);
|
|
208
|
+
}
|
|
209
|
+
// A member that was parked or removed leaves no stale generated file behind.
|
|
210
|
+
let existing = [];
|
|
211
|
+
try {
|
|
212
|
+
existing = fs.readdirSync(dir);
|
|
213
|
+
} catch {}
|
|
214
|
+
for (const entry of existing) {
|
|
215
|
+
const file = path.join(dir, entry);
|
|
216
|
+
if (!entry.endsWith(format.ext) || wanted.has(file) || !isGenerated(file)) continue;
|
|
217
|
+
if (!dryRun) fs.unlinkSync(file);
|
|
218
|
+
result.removed.push(path.relative(root, file));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function summaryLine(result, { dryRun = false } = {}) {
|
|
225
|
+
const count = result.members.length;
|
|
226
|
+
const parts = [`${count} ${count === 1 ? 'member' : 'members'} exported to claude code, cursor, and codex`];
|
|
227
|
+
if (result.written.length) parts.push(`${result.written.length} ${dryRun ? 'would change' : 'files written'}`);
|
|
228
|
+
if (result.removed.length) parts.push(`${result.removed.length} old ${dryRun ? 'would be removed' : 'removed'}`);
|
|
229
|
+
if (!result.written.length && !result.removed.length) parts.push('already up to date');
|
|
230
|
+
if (result.parked.length) parts.push(`${result.parked.length} parked skipped`);
|
|
231
|
+
return parts.join(', ');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
GENERATED_MARK,
|
|
236
|
+
agentName,
|
|
237
|
+
exportMembers,
|
|
238
|
+
frontmatterOf,
|
|
239
|
+
summaryLine,
|
|
240
|
+
};
|
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 };
|