atris 3.61.0 → 3.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -5
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +1 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +33 -8
- package/commands/mission.js +112 -3
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/team.js +82 -10
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +36 -26
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +283 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/member-engine.js +11 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +37 -0
- package/lib/roster-markdown.js +1 -1
- package/lib/roster-prep.js +289 -0
- package/lib/roster-runs.js +389 -0
- package/lib/roster-suggest.js +145 -0
- package/lib/router-brain.js +111 -3
- package/lib/self-drive.js +26 -2
- package/lib/state-detection.js +3 -3
- package/lib/task-db.js +38 -3
- package/lib/team-lineup.js +7 -2
- package/lib/todo.js +1 -1
- package/lib/wish-audit.js +1 -1
- package/package.json +1 -1
- package/scripts/det/cpu-hotspots.js +216 -0
- package/scripts/det/ytnotes +105 -19
- package/scripts/det/ytquote-repair.js +18 -4
- package/templates/members/host/MEMBER.md +5 -3
- package/templates/members/host/MISSION.md +1 -1
- package/templates/members/host/skills/host/SKILL.md +64 -16
package/commands/engine.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// atris engine roster confirm renew every dated worker for 30 days
|
|
12
12
|
// atris engine roster session [clear] this shell's own changes
|
|
13
13
|
// atris engine roster --available tools and models on this machine
|
|
14
|
-
// atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]
|
|
15
|
-
// atris engine assign <job> --remove <tool> | --clear [--session | --everywhere]
|
|
14
|
+
// atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]
|
|
15
|
+
// atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]
|
|
16
16
|
// jobs are open-ended: search, build, review, or your own ("small build")
|
|
17
17
|
// atris engine cursor make cursor the default engine here
|
|
18
18
|
// atris engine reset back to the house default (atris-fast)
|
|
@@ -36,6 +36,8 @@ const { parseScopeFlag } = require('../lib/cli-scope');
|
|
|
36
36
|
const { isFreshWorkspace, speakFirstMinute } = require('../lib/first-minute');
|
|
37
37
|
const { teamRosterView } = require('../lib/member-engine');
|
|
38
38
|
const { engineRunsView, availableModels } = require('../lib/roster-models');
|
|
39
|
+
const { readRosterRuns, workerRuns, summarizeRuns, recentRuns, renderRunLine, DEFAULT_DAYS: RUN_DAYS } = require('../lib/roster-runs');
|
|
40
|
+
const { attachSuggestions } = require('../lib/roster-suggest');
|
|
39
41
|
const {
|
|
40
42
|
ENGINE_ROLES,
|
|
41
43
|
ENGINE_JOBS,
|
|
@@ -70,6 +72,8 @@ const {
|
|
|
70
72
|
requireEngineBin,
|
|
71
73
|
engineDoctorReport,
|
|
72
74
|
engineFailureHealthStatus,
|
|
75
|
+
engineReadyAt,
|
|
76
|
+
coolingView,
|
|
73
77
|
setEngineOverrides,
|
|
74
78
|
setEngineHealth,
|
|
75
79
|
} = require('../lib/engine-registry');
|
|
@@ -144,7 +148,7 @@ function resolveDefaultEngine(root = process.cwd()) {
|
|
|
144
148
|
if (saved) return { name: saved, source: 'saved' };
|
|
145
149
|
const ready = new Set(
|
|
146
150
|
engineRegistryView(root)
|
|
147
|
-
.filter((engine) => engine
|
|
151
|
+
.filter((engine) => engineReadyAt(engine))
|
|
148
152
|
.map((engine) => engine.id)
|
|
149
153
|
);
|
|
150
154
|
if (ready.has(HOUSE_ENGINE)) return { name: HOUSE_ENGINE, source: 'house' };
|
|
@@ -941,7 +945,7 @@ function printRoster(root, { scope = 'workspace' } = {}) {
|
|
|
941
945
|
const list = roster(root);
|
|
942
946
|
const scoped = scope === 'global'
|
|
943
947
|
? list
|
|
944
|
-
: list.filter((engine) => engine.installed || engine.default || engine.health.status
|
|
948
|
+
: list.filter((engine) => engine.installed || engine.default || ['ready', 'cooling'].includes(engine.health.status));
|
|
945
949
|
const found = scoped.filter((e) => e.installed).length;
|
|
946
950
|
const current = resolveDefaultEngine(root);
|
|
947
951
|
console.log('');
|
|
@@ -954,6 +958,7 @@ function printRoster(root, { scope = 'workspace' } = {}) {
|
|
|
954
958
|
console.log(` ${mark} ${engine.id.padEnd(12)} ${state.padEnd(13)} ${engine.tier.padEnd(4)} ${roles}`);
|
|
955
959
|
const details = [`models: ${engine.models.join(', ')}`];
|
|
956
960
|
if (engine.duty) details.push(`duty: ${engine.duty}`);
|
|
961
|
+
if (engine.health.status === 'cooling') details.push(coolingView(engine).text);
|
|
957
962
|
console.log(` ${details.join(' ')}`);
|
|
958
963
|
}
|
|
959
964
|
if (scope !== 'global' && list.length > scoped.length) {
|
|
@@ -1001,17 +1006,21 @@ function workerRow(step, index, leadIndex) {
|
|
|
1001
1006
|
const { worker } = step;
|
|
1002
1007
|
const runs = worker.error ? null : engineRunsView(worker.engine, { model: worker.model || '', effort: worker.effort || '' });
|
|
1003
1008
|
const status = index === leadIndex ? 'leads' : step.skip ? 'skipped' : 'backup';
|
|
1004
|
-
const why = step.skip === 'bad line' ? `bad line: ${worker.error}`
|
|
1009
|
+
const why = step.skip === 'bad line' ? `bad line: ${worker.error}`
|
|
1010
|
+
: step.skip === 'cooling' && step.cooling ? step.cooling.text
|
|
1011
|
+
: step.skip ? WORKER_SKIP_WORDS[step.skip] : '';
|
|
1005
1012
|
return {
|
|
1006
1013
|
engine: worker.engine || null,
|
|
1007
1014
|
model: worker.model || null,
|
|
1008
1015
|
effort: worker.effort || null,
|
|
1009
1016
|
max_seconds: Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : null,
|
|
1017
|
+
prep: worker.prep ? rosterJobLabel(worker.prep) : null,
|
|
1010
1018
|
until: worker.until || null,
|
|
1011
1019
|
line: worker.line || null,
|
|
1012
1020
|
...(worker.error ? { text: worker.text || '' } : {}),
|
|
1013
1021
|
status,
|
|
1014
1022
|
why,
|
|
1023
|
+
...(step.cooling ? { cooling_until: step.cooling.until, cooling_reason: step.cooling.reason } : {}),
|
|
1015
1024
|
runs,
|
|
1016
1025
|
};
|
|
1017
1026
|
}
|
|
@@ -1039,7 +1048,8 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
|
|
|
1039
1048
|
const status = !pick ? 'router'
|
|
1040
1049
|
: first && first.step.skip === 'expired' ? 'expired'
|
|
1041
1050
|
: first && leadIndex === first.index ? 'picked'
|
|
1042
|
-
: '
|
|
1051
|
+
: first && first.step.skip === 'cooling' ? 'cooling'
|
|
1052
|
+
: 'not ready';
|
|
1043
1053
|
const lead = leadIndex === -1 ? 'none'
|
|
1044
1054
|
: first && leadIndex === first.index ? 'first'
|
|
1045
1055
|
: good[1] && leadIndex === good[1].index ? 'backup'
|
|
@@ -1066,12 +1076,15 @@ function jobRosterRow(job, role, root, state, registry, now, key = role) {
|
|
|
1066
1076
|
model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : null,
|
|
1067
1077
|
effort: resolved.engine && resolved.engine.roster_effort ? resolved.engine.roster_effort : null,
|
|
1068
1078
|
max_seconds: pick && Number(pick.max_seconds) > 0 ? Number(pick.max_seconds) : null,
|
|
1079
|
+
prep: first && first.step.worker.prep ? rosterJobLabel(first.step.worker.prep) : null,
|
|
1080
|
+
backup_prep: good[1] && good[1].step.worker.prep ? rosterJobLabel(good[1].step.worker.prep) : null,
|
|
1069
1081
|
runs: runs || nowRuns,
|
|
1070
1082
|
backup_runs: runsOf(good[1]),
|
|
1071
1083
|
now_runs: nowRuns,
|
|
1072
1084
|
workers: walk.map((step, index) => workerRow(step, index, leadIndex)),
|
|
1073
1085
|
lead,
|
|
1074
1086
|
status,
|
|
1087
|
+
...(status === 'cooling' ? { cooling: first.step.cooling.text } : {}),
|
|
1075
1088
|
reason: resolved.reason,
|
|
1076
1089
|
};
|
|
1077
1090
|
}
|
|
@@ -1127,12 +1140,19 @@ function renderWorkerLines(row) {
|
|
|
1127
1140
|
const width = Math.max(...row.workers.map((worker) => (worker.runs ? worker.runs.text : worker.text || '').length));
|
|
1128
1141
|
return row.workers.map((worker, index) => {
|
|
1129
1142
|
const what = (worker.runs ? worker.runs.text : worker.text || '').padEnd(width);
|
|
1130
|
-
const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.until ? `until ${worker.until}` : ''].filter(Boolean);
|
|
1143
|
+
const extras = [worker.max_seconds ? rosterMaxText(worker.max_seconds) : '', worker.prep ? `prepped by ${worker.prep}` : '', worker.until ? `until ${worker.until}` : ''].filter(Boolean);
|
|
1131
1144
|
const state = worker.status === 'leads' ? 'leads now' : worker.status === 'backup' ? 'backup' : `skipped, ${worker.why}`;
|
|
1132
1145
|
return ` ${index + 1}. ${what} ${[state, ...extras].join(', ')}`.trimEnd();
|
|
1133
1146
|
});
|
|
1134
1147
|
}
|
|
1135
1148
|
|
|
1149
|
+
// Each worker's recent record under its job, only for workers with runs.
|
|
1150
|
+
function renderWorkerRecords(row) {
|
|
1151
|
+
return (row.workers || [])
|
|
1152
|
+
.filter((worker) => worker.record)
|
|
1153
|
+
.map((worker) => ` ${worker.runs ? worker.runs.text : worker.engine}: ${worker.record.text}`);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1136
1156
|
function renderJobRoster(rows) {
|
|
1137
1157
|
const width = Math.max(7, ...rows.map((row) => row.job.length));
|
|
1138
1158
|
const ownerWidth = Math.max(24, ...rows.filter((row) => row.pick && row.runs).map((row) => row.runs.text.length));
|
|
@@ -1143,16 +1163,18 @@ function renderJobRoster(rows) {
|
|
|
1143
1163
|
const fallsTo = `${row.like ? `falls back to ${row.like}` : 'router decides'}: ${nowText}`;
|
|
1144
1164
|
if (!row.pick) return `${label} no pick, ${fallsTo}`;
|
|
1145
1165
|
const owner = row.runs.text.padEnd(ownerWidth);
|
|
1146
|
-
const backup = row.backup_runs ? `backup ${row.backup_runs.text}` : 'no backup';
|
|
1147
|
-
const cap = row.max_seconds ? `${rosterMaxText(row.max_seconds)}, ` : ''
|
|
1166
|
+
const backup = row.backup_runs ? `backup ${row.backup_runs.text}${row.backup_prep ? ` prepped by ${row.backup_prep}` : ''}` : 'no backup';
|
|
1167
|
+
const cap = `${row.max_seconds ? `${rosterMaxText(row.max_seconds)}, ` : ''}${row.prep ? `prepped by ${row.prep}, ` : ''}`;
|
|
1148
1168
|
const date = untilText(row.pick);
|
|
1149
1169
|
const fallback = row.lead === 'backup' ? 'using backup' : row.lead === 'later' ? `using ${nowText}` : fallsTo;
|
|
1150
1170
|
const status = row.status === 'expired' ? `expired, ${fallback}`
|
|
1151
|
-
: row.status === '
|
|
1152
|
-
:
|
|
1171
|
+
: row.status === 'cooling' ? `${row.cooling}, ${fallback}`
|
|
1172
|
+
: row.status === 'not ready' ? `not ready, ${fallback}`
|
|
1173
|
+
: date;
|
|
1153
1174
|
const where = row.file ? `${row.from} (${row.file})` : row.from;
|
|
1154
1175
|
const head = `${label} ${owner} ${backup.padEnd(backupWidth)} ${cap}${status}, ${where}`.trimEnd();
|
|
1155
|
-
|
|
1176
|
+
const suggestion = row.suggestion ? [` suggestion: ${row.suggestion.text}`] : [];
|
|
1177
|
+
return [head, ...renderWorkerLines(row), ...renderWorkerRecords(row), ...suggestion].join('\n');
|
|
1156
1178
|
}).join('\n');
|
|
1157
1179
|
}
|
|
1158
1180
|
|
|
@@ -1234,15 +1256,62 @@ function runRosterSessionCommand(rest, json, root, now) {
|
|
|
1234
1256
|
return 0;
|
|
1235
1257
|
}
|
|
1236
1258
|
|
|
1259
|
+
// Attach each worker's recent record (last 7 days) to the roster report, and
|
|
1260
|
+
// at most one suggestion per job when that record says the order is wrong.
|
|
1261
|
+
// Only the roster command reads the whole tail of the run file; the boot
|
|
1262
|
+
// line reads a smaller tail just to count suggestions. Nothing here writes
|
|
1263
|
+
// the roster: a suggestion is a line to read and a command a person may run.
|
|
1264
|
+
function attachRunRecords(report, root, now = new Date()) {
|
|
1265
|
+
const runs = readRosterRuns(root, { now, days: RUN_DAYS });
|
|
1266
|
+
if (!runs.length) return report;
|
|
1267
|
+
for (const row of report.jobs || []) {
|
|
1268
|
+
for (const worker of row.workers || []) {
|
|
1269
|
+
if (!worker.engine) continue;
|
|
1270
|
+
const record = summarizeRuns(workerRuns(runs, { job: row.key || row.job, engine: worker.engine, model: worker.model || '' }), { days: RUN_DAYS });
|
|
1271
|
+
if (record) worker.record = record;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
attachSuggestions(report.jobs, runs);
|
|
1275
|
+
return report;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const RUNS_LIST_DAYS = 30;
|
|
1279
|
+
const RUNS_LIST_LIMIT = 20;
|
|
1280
|
+
|
|
1281
|
+
// The recent attempts, newest first, one plain line each.
|
|
1282
|
+
function runRosterRunsCommand(job, json, root, now) {
|
|
1283
|
+
const runs = recentRuns(readRosterRuns(root, { now, days: RUNS_LIST_DAYS }), { job, limit: RUNS_LIST_LIMIT });
|
|
1284
|
+
if (json) {
|
|
1285
|
+
console.log(JSON.stringify({ job: job || null, days: RUNS_LIST_DAYS, runs }, null, 2));
|
|
1286
|
+
return 0;
|
|
1287
|
+
}
|
|
1288
|
+
if (!runs.length) {
|
|
1289
|
+
console.log(job ? `no ${job} runs recorded in the last ${RUNS_LIST_DAYS} days` : `no runs recorded in the last ${RUNS_LIST_DAYS} days`);
|
|
1290
|
+
return 0;
|
|
1291
|
+
}
|
|
1292
|
+
console.log([`recent ${job ? `${job} ` : ''}runs, newest first`, ...runs.map(renderRunLine)].join('\n'));
|
|
1293
|
+
return 0;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1237
1296
|
function runRosterCommand(args, root, now = new Date()) {
|
|
1238
1297
|
const json = args.includes('--json');
|
|
1239
1298
|
const available = args.includes('--available');
|
|
1299
|
+
const runsAt = args.indexOf('--runs');
|
|
1300
|
+
if (runsAt !== -1) {
|
|
1301
|
+
const other = args.slice(0, runsAt).filter((arg) => arg !== '--json');
|
|
1302
|
+
const job = args.slice(runsAt + 1).filter((arg) => !String(arg).startsWith('--')).join(' ').trim();
|
|
1303
|
+
if (other.length || available) {
|
|
1304
|
+
console.error('usage: atris engine roster --runs [job] [--json]');
|
|
1305
|
+
return 2;
|
|
1306
|
+
}
|
|
1307
|
+
return runRosterRunsCommand(job, json, root, now);
|
|
1308
|
+
}
|
|
1240
1309
|
const rest = args.filter((arg) => arg !== '--json' && arg !== '--available');
|
|
1241
1310
|
const known = !rest.length
|
|
1242
1311
|
|| (rest.length === 1 && ['confirm', 'session'].includes(rest[0]))
|
|
1243
1312
|
|| (rest.length === 2 && rest[0] === 'session' && rest[1] === 'clear');
|
|
1244
1313
|
if (!known || (available && rest.length)) {
|
|
1245
|
-
console.error('usage: atris engine roster [confirm | session [clear] | --available] [--json]');
|
|
1314
|
+
console.error('usage: atris engine roster [confirm | session [clear] | --available | --runs [job]] [--json]');
|
|
1246
1315
|
return 2;
|
|
1247
1316
|
}
|
|
1248
1317
|
if (available) {
|
|
@@ -1252,13 +1321,13 @@ function runRosterCommand(args, root, now = new Date()) {
|
|
|
1252
1321
|
}
|
|
1253
1322
|
if (rest[0] === 'session') return runRosterSessionCommand(rest, json, root, now);
|
|
1254
1323
|
if (rest[0] === 'confirm') confirmRoster(root, now);
|
|
1255
|
-
const report = rosterReport(root, now);
|
|
1324
|
+
const report = attachRunRecords(rosterReport(root, now), root, now);
|
|
1256
1325
|
if (json) console.log(JSON.stringify(report, null, 2));
|
|
1257
1326
|
else console.log(`${renderRosterReport(report)}\n\n${AVAILABLE_HINT}`);
|
|
1258
1327
|
return 0;
|
|
1259
1328
|
}
|
|
1260
1329
|
|
|
1261
|
-
const ASSIGN_USAGE = 'usage: atris engine assign <job> <tool> [--model <m>] [--effort low|medium|high|xhigh|max] [--max "20 min"] [--days <n>] [--add] [--backup "<tool> [model]"] [--like search|build|review] [--session | --everywhere], or atris engine assign <job> --remove <tool> | --clear. jobs live in atris/ROSTER.md (~/.atris/ROSTER.md with --everywhere, this shell only with --session) and you can edit that file any time';
|
|
1330
|
+
const ASSIGN_USAGE = 'usage: atris engine assign <job> <tool> [--model <m>] [--effort low|medium|high|xhigh|max] [--max "20 min"] [--prep <job>] [--days <n>] [--add] [--backup "<tool> [model]"] [--like search|build|review] [--session | --everywhere], atris engine assign <job> --promote "<tool> [model]" to move an existing worker to the top unchanged, or atris engine assign <job> --remove <tool> | --clear. jobs live in atris/ROSTER.md (~/.atris/ROSTER.md with --everywhere, this shell only with --session) and you can edit that file any time';
|
|
1262
1331
|
|
|
1263
1332
|
function runAssignCommand(args, root, now = new Date()) {
|
|
1264
1333
|
const job = args[0] && !String(args[0]).startsWith('--') ? args[0] : '';
|
|
@@ -1283,13 +1352,14 @@ function runAssignCommand(args, root, now = new Date()) {
|
|
|
1283
1352
|
const flags = {};
|
|
1284
1353
|
for (let i = 0; i < rest.length; i += 1) {
|
|
1285
1354
|
const arg = rest[i];
|
|
1286
|
-
if (!['--model', '--backup', '--days', '--like', '--effort', '--max', '--remove'].includes(arg) || !rest[i + 1] || rest[i + 1].startsWith('--')) {
|
|
1355
|
+
if (!['--model', '--backup', '--days', '--like', '--effort', '--max', '--prep', '--remove', '--promote'].includes(arg) || !rest[i + 1] || rest[i + 1].startsWith('--')) {
|
|
1287
1356
|
console.error(`invalid assign option "${arg}"`);
|
|
1288
1357
|
return 2;
|
|
1289
1358
|
}
|
|
1290
1359
|
flags[arg.slice(2)] = rest[++i];
|
|
1291
1360
|
}
|
|
1292
1361
|
const removing = flags.remove !== undefined;
|
|
1362
|
+
const promoting = flags.promote !== undefined;
|
|
1293
1363
|
if (clear && (engine || Object.keys(flags).length || add)) {
|
|
1294
1364
|
console.error('use --clear without an engine or other options');
|
|
1295
1365
|
return 2;
|
|
@@ -1298,12 +1368,16 @@ function runAssignCommand(args, root, now = new Date()) {
|
|
|
1298
1368
|
console.error('use --remove <tool> without other options');
|
|
1299
1369
|
return 2;
|
|
1300
1370
|
}
|
|
1301
|
-
if (
|
|
1371
|
+
if (promoting && (engine || Object.keys(flags).length > 1 || add)) {
|
|
1372
|
+
console.error('use --promote "<tool> [model]" without an engine or other options');
|
|
1373
|
+
return 2;
|
|
1374
|
+
}
|
|
1375
|
+
if (!clear && !removing && !promoting && !engine) {
|
|
1302
1376
|
console.error(ASSIGN_USAGE);
|
|
1303
1377
|
return 2;
|
|
1304
1378
|
}
|
|
1305
1379
|
try {
|
|
1306
|
-
setRosterPick(job, engine, { ...flags, clear, add, session, everywhere, now }, root);
|
|
1380
|
+
setRosterPick(job, engine, { ...flags, clear, add, session, everywhere, now, note: (text) => console.log(text) }, root);
|
|
1307
1381
|
printJobRoster(root, now);
|
|
1308
1382
|
return 0;
|
|
1309
1383
|
} catch (err) {
|
|
@@ -1942,8 +2016,8 @@ function runDispatchCommand(args, root) {
|
|
|
1942
2016
|
: `engine dispatch: ${err.message}`);
|
|
1943
2017
|
return 2;
|
|
1944
2018
|
}
|
|
1945
|
-
// The build line's model, effort,
|
|
1946
|
-
// picks this same engine for build.
|
|
2019
|
+
// The build line's model, effort, time cap, and prep job ride along when
|
|
2020
|
+
// the roster picks this same engine for build.
|
|
1947
2021
|
const pin = rosterPinFor('executor', canonical, root);
|
|
1948
2022
|
return runDispatchFlight({
|
|
1949
2023
|
root,
|
|
@@ -1955,6 +2029,7 @@ function runDispatchCommand(args, root) {
|
|
|
1955
2029
|
...(pin.roster_model ? { model: pin.roster_model } : {}),
|
|
1956
2030
|
...(pin.roster_effort ? { effort: pin.roster_effort } : {}),
|
|
1957
2031
|
...(pin.roster_max_seconds ? { maxSeconds: pin.roster_max_seconds } : {}),
|
|
2032
|
+
...(pin.roster_prep ? { prep: pin.roster_prep } : {}),
|
|
1958
2033
|
}).then((flight) => {
|
|
1959
2034
|
if (json) console.log(JSON.stringify(flight, null, 2));
|
|
1960
2035
|
return flight.paused.length ? 1 : 0;
|
|
@@ -2085,7 +2160,7 @@ function engineCommand(args = [], deps = {}) {
|
|
|
2085
2160
|
if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
2086
2161
|
console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
|
|
2087
2162
|
console.log('\n long read-only asks: atris engine ask "..." --engine agy --timeout <seconds>\n quick asks default to 120 seconds; explicit jobs allow up to 3600\n follow live work with atris engine watch latest');
|
|
2088
|
-
console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
|
|
2163
|
+
console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
|
|
2089
2164
|
return 0;
|
|
2090
2165
|
}
|
|
2091
2166
|
if (isFreshWorkspace(root)) {
|
package/commands/host.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { hostAction } = require('../lib/host');
|
|
4
|
+
const { apiRequestJson } = require('../utils/api');
|
|
5
|
+
const { loadCredentials, decodeJwtClaims } = require('../utils/auth');
|
|
4
6
|
|
|
5
7
|
function parse(argv) {
|
|
6
8
|
const options = {};
|
|
7
9
|
const positionals = [];
|
|
8
|
-
const flags = new Set(['id', 'name', 'team', 'manager', 'door', 'now', 'started', 'question', 'event-id', 'from', 'text', 'reply-to', 'reply-to-ref', 'ref', 'decision', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as', 'when', 'at']);
|
|
10
|
+
const flags = new Set(['id', 'name', 'team', 'manager', 'door', 'now', 'started', 'question', 'event-id', 'from', 'text', 'reply-to', 'reply-to-ref', 'ref', 'decision', 'patch', 'expected-revision', 'source', 'evidence', 'reason', 'activity', 'as', 'when', 'at', 'room', 'slack-business', 'limit']);
|
|
9
11
|
for (let i = 0; i < argv.length; i += 1) {
|
|
10
12
|
const item = argv[i];
|
|
11
13
|
if (item === '--json') { options.json = true; continue; }
|
|
14
|
+
if (item === '--dry-run') { options.dryRun = true; continue; }
|
|
12
15
|
if (item.startsWith('--')) {
|
|
13
16
|
const key = item.slice(2);
|
|
14
17
|
if (!flags.has(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`invalid --${key} value`);
|
|
@@ -18,8 +21,49 @@ function parse(argv) {
|
|
|
18
21
|
return { options, positionals };
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
async function hostDeliver(root, options = {}, deps = {}) {
|
|
25
|
+
const { room, slack_business_id: businessId, messages } = hostAction(root, 'delivery');
|
|
26
|
+
if (!businessId) throw new Error('slack business id is missing; run atris host setup --slack-business <business id>');
|
|
27
|
+
if (room === 'personal') throw new Error('host delivery is for group rooms only');
|
|
28
|
+
if (options.limit != null && !/^(0|[1-9]\d*)$/.test(options.limit)) throw new Error('limit must be a non-negative integer');
|
|
29
|
+
const eligible = messages.filter((message) => /^slack:[UW][A-Z0-9]+$/.test(message.door || '') && message.draft !== true);
|
|
30
|
+
const selected = eligible.slice(0, options.limit == null ? undefined : Number(options.limit));
|
|
31
|
+
const result = { sent: 0, skipped: messages.length - eligible.length, failed: 0 };
|
|
32
|
+
if (options.dryRun) {
|
|
33
|
+
result.would_send = selected.map((message) => ({ id: message.id, to: message.door.slice(6), kind: message.kind, text: String(message.text).replace(/\s+/g, ' ').slice(0, 80) }));
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
if (!selected.length) return result;
|
|
37
|
+
const credentials = (deps.loadCredentials || loadCredentials)();
|
|
38
|
+
if (!credentials?.token || credentials.source === 'agent_token_file' || decodeJwtClaims(credentials.token)?.type === 'agent_access') {
|
|
39
|
+
throw new Error('log in as an Atris user with atris login before delivering');
|
|
40
|
+
}
|
|
41
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
42
|
+
for (const message of selected) {
|
|
43
|
+
try {
|
|
44
|
+
const response = await request(`/business/${encodeURIComponent(businessId)}/host/slack/send`, {
|
|
45
|
+
method: 'POST', token: credentials.token, retries: 0,
|
|
46
|
+
body: { slack_user_id: message.door.slice(6), text: message.text, host_message_id: message.id },
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) throw new Error(`${response.status ? `http ${response.status}` : 'network error'}: ${response.error || 'request failed'}`);
|
|
49
|
+
if (typeof response.data?.ts !== 'string' || !response.data.ts.trim()) throw new Error('response is missing a Slack timestamp');
|
|
50
|
+
hostAction(root, 'sent', { id: message.id, ref: response.data.ts });
|
|
51
|
+
result.sent += 1;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
result.failed = 1;
|
|
54
|
+
result.failure = { id: message.id, reason: error.message };
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
21
61
|
function human(command, result) {
|
|
62
|
+
if (command === 'deliver') return result.would_send
|
|
63
|
+
? [...result.would_send.map((message) => `${message.to} ${message.kind}: ${message.text}`), `dry run: ${result.would_send.length} would send, ${result.skipped} skipped`].join('\n')
|
|
64
|
+
: `host delivery: ${result.sent} sent, ${result.skipped} skipped, ${result.failure ? `failed ${result.failure.id}: ${result.failure.reason}` : '0 failed'}`;
|
|
22
65
|
if (command === 'room') return result.text;
|
|
66
|
+
if (command === 'import') return `import complete: ${result.added} added, ${result.skipped} skipped, ${result.refused} refused, ${result.links_added} links added, ${result.links_skipped} links skipped`;
|
|
23
67
|
if (command === 'due') return result.length ? result.map((entry) => `${entry.name} (${entry.id}) is due for a question.`).join('\n') : 'no questions due';
|
|
24
68
|
if (command === 'outbox') return result.length ? result.map((entry) => `${entry.id} ${entry.kind} to ${entry.to}: ${entry.text}`).join('\n') : 'outbox empty';
|
|
25
69
|
if (command === 'schedule') return result.length ? result.map((entry) => `${entry.attempt_id}: ${entry.names.a} (${entry.a}) and ${entry.names.b} (${entry.b}), ${entry.activity}`).join('\n') : 'no introductions need scheduling';
|
|
@@ -36,21 +80,29 @@ function human(command, result) {
|
|
|
36
80
|
return `${command} complete: ${JSON.stringify(result)}`;
|
|
37
81
|
}
|
|
38
82
|
|
|
39
|
-
function hostCommand(argv = process.argv.slice(3), root = process.cwd()) {
|
|
83
|
+
function hostCommand(argv = process.argv.slice(3), root = process.cwd(), deps = {}) {
|
|
40
84
|
let jsonOutput = argv.includes('--json');
|
|
41
85
|
try {
|
|
42
86
|
const [command, ...rest] = argv;
|
|
43
87
|
if (!command || command === '--help' || command === '-h') {
|
|
44
|
-
const usage = 'usage: atris host join|leave|pause|resume|forget|due|ask|receive|card|link|propose|people|outbox|sent|room|view|schedule|scheduled [options]';
|
|
88
|
+
const usage = 'usage: atris host setup|join|import|leave|pause|resume|forget|due|ask|receive|card|link|propose|people|outbox|deliver|sent|room|view|schedule|scheduled [options]';
|
|
45
89
|
console.log(jsonOutput ? JSON.stringify({ usage }) : usage);
|
|
46
90
|
return;
|
|
47
91
|
}
|
|
48
92
|
const { options, positionals } = parse(rest);
|
|
49
93
|
jsonOutput = options.json;
|
|
50
94
|
const args = { ...options };
|
|
95
|
+
if (command === 'import') args.file = positionals[0];
|
|
51
96
|
if (['leave', 'pause', 'resume', 'forget', 'ask', 'card', 'sent', 'scheduled'].includes(command)) args.id = positionals[0];
|
|
52
97
|
if (['link', 'propose'].includes(command)) { args.a = positionals[0]; args.b = positionals[1]; }
|
|
53
98
|
if (command === 'receive') args.eventId = options.eventId;
|
|
99
|
+
if (command === 'deliver') return hostDeliver(root, args, deps).then((result) => {
|
|
100
|
+
console.log(jsonOutput ? JSON.stringify(result) : human(command, result));
|
|
101
|
+
if (result.failed) process.exitCode = 1;
|
|
102
|
+
}).catch((error) => {
|
|
103
|
+
console.error(jsonOutput ? JSON.stringify({ error: error.message }) : `host: ${error.message}`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
});
|
|
54
106
|
const result = hostAction(root, command, args);
|
|
55
107
|
console.log(jsonOutput ? JSON.stringify(result) : human(command, result));
|
|
56
108
|
} catch (error) {
|
|
@@ -59,4 +111,4 @@ function hostCommand(argv = process.argv.slice(3), root = process.cwd()) {
|
|
|
59
111
|
}
|
|
60
112
|
}
|
|
61
113
|
|
|
62
|
-
module.exports = { hostCommand };
|
|
114
|
+
module.exports = { hostCommand, hostDeliver, parse };
|
package/commands/land.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const
|
|
3
|
+
const gitSpawn = require('../lib/git-spawn');
|
|
4
4
|
const { compactErrorPayload, printCliJson } = require('../lib/cli-json');
|
|
5
5
|
const { listWorktrees, statusCounts } = require('./worktree');
|
|
6
6
|
|
|
@@ -9,6 +9,14 @@ const DEFAULT_STALE_HOURS = 48;
|
|
|
9
9
|
const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
|
|
10
10
|
const PROTECTED_BRANCHES = new Set(['main', 'master']);
|
|
11
11
|
|
|
12
|
+
// Through the module object so tests can count git spawns with a spy.
|
|
13
|
+
function runGit(args, opts) {
|
|
14
|
+
return gitSpawn.runGit(args, opts);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CHERRY_CACHE_FILE = path.join('.atris', 'state', 'cherry-cache.json');
|
|
18
|
+
const CHERRY_CACHE_MAX = 2000;
|
|
19
|
+
|
|
12
20
|
// Pluralize a count + noun ("1 change", "2 changes").
|
|
13
21
|
function countLabel(n, word) {
|
|
14
22
|
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
@@ -157,10 +165,11 @@ function checkoutKeepLine(name, bound, now) {
|
|
|
157
165
|
function listBranches(root, base = '') {
|
|
158
166
|
// With a base, ask git for ahead counts in the same single spawn
|
|
159
167
|
// (%(ahead-behind:) needs git >= 2.41; on failure we retry without it and
|
|
160
|
-
// collectBoard falls back to per-branch aheadCount).
|
|
168
|
+
// collectBoard falls back to per-branch aheadCount). The branch sha comes
|
|
169
|
+
// along in the same spawn so the cherry cache needs no per-branch lookup.
|
|
161
170
|
const format = base
|
|
162
|
-
? `%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:${base})`
|
|
163
|
-
: '%(refname:short)%09%(committerdate:unix)';
|
|
171
|
+
? `%(refname:short)%09%(committerdate:unix)%09%(objectname)%09%(ahead-behind:${base})`
|
|
172
|
+
: '%(refname:short)%09%(committerdate:unix)%09%(objectname)';
|
|
164
173
|
const result = runGit(['for-each-ref', 'refs/heads', `--format=${format}`], { cwd: root, check: false });
|
|
165
174
|
if (result.status !== 0) {
|
|
166
175
|
if (base) return listBranches(root);
|
|
@@ -170,8 +179,9 @@ function listBranches(root, base = '') {
|
|
|
170
179
|
.split(/\r?\n/)
|
|
171
180
|
.filter(Boolean)
|
|
172
181
|
.map((line) => {
|
|
173
|
-
const [name, date, aheadBehind] = line.split('\t');
|
|
182
|
+
const [name, date, sha, aheadBehind] = line.split('\t');
|
|
174
183
|
const entry = { name, lastCommitUnix: Number(date) };
|
|
184
|
+
if (sha) entry.sha = sha;
|
|
175
185
|
if (aheadBehind !== undefined) {
|
|
176
186
|
const ahead = Number(aheadBehind.split(' ')[0]);
|
|
177
187
|
if (Number.isFinite(ahead)) entry.ahead = ahead;
|
|
@@ -206,6 +216,82 @@ function cherryStats(root, base, ref) {
|
|
|
206
216
|
return { landedElsewhere, unique };
|
|
207
217
|
}
|
|
208
218
|
|
|
219
|
+
// `git cherry <base> <ref>` depends only on the two commit shas, so its
|
|
220
|
+
// counts are cached per (base sha, ref sha) under .atris/state. The cache is
|
|
221
|
+
// only written where .atris already exists; a missing or corrupt file means
|
|
222
|
+
// recompute. Entries for refs not seen this run are dropped on save.
|
|
223
|
+
function cherryCachePath(root) {
|
|
224
|
+
return path.join(root, CHERRY_CACHE_FILE);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function loadCherryCache(root) {
|
|
228
|
+
const cache = { file: cherryCachePath(root), entries: {}, used: {}, dirty: false, writable: false };
|
|
229
|
+
try {
|
|
230
|
+
cache.writable = fs.statSync(path.join(root, '.atris')).isDirectory();
|
|
231
|
+
} catch {
|
|
232
|
+
cache.writable = false;
|
|
233
|
+
}
|
|
234
|
+
if (!cache.writable) return cache;
|
|
235
|
+
try {
|
|
236
|
+
const parsed = JSON.parse(fs.readFileSync(cache.file, 'utf8'));
|
|
237
|
+
if (parsed && parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object') {
|
|
238
|
+
cache.entries = parsed.entries;
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
cache.entries = {};
|
|
242
|
+
}
|
|
243
|
+
return cache;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function validCherryEntry(entry) {
|
|
247
|
+
return entry && Number.isInteger(entry.landedElsewhere) && Number.isInteger(entry.unique)
|
|
248
|
+
&& entry.landedElsewhere >= 0 && entry.unique >= 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function cachedCherryStats(cache, cwd, base, baseSha, ref, refSha) {
|
|
252
|
+
if (!cache || !baseSha || !refSha) return cherryStats(cwd, base, ref);
|
|
253
|
+
const key = `${baseSha}:${refSha}`;
|
|
254
|
+
const hit = cache.entries[key];
|
|
255
|
+
if (validCherryEntry(hit)) {
|
|
256
|
+
cache.used[key] = hit;
|
|
257
|
+
return { landedElsewhere: hit.landedElsewhere, unique: hit.unique };
|
|
258
|
+
}
|
|
259
|
+
// Cherry the exact commits the key names, so a ref that moves mid-run
|
|
260
|
+
// can never file one pair's result under another pair's key.
|
|
261
|
+
const result = runGit(['cherry', baseSha, refSha], { cwd, check: false });
|
|
262
|
+
if (result.status !== 0) return { landedElsewhere: 0, unique: 0 };
|
|
263
|
+
const stats = { landedElsewhere: 0, unique: 0 };
|
|
264
|
+
for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
265
|
+
if (line.startsWith('- ')) stats.landedElsewhere += 1;
|
|
266
|
+
else if (line.startsWith('+ ')) stats.unique += 1;
|
|
267
|
+
}
|
|
268
|
+
cache.used[key] = { ...stats };
|
|
269
|
+
cache.dirty = true;
|
|
270
|
+
return stats;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function saveCherryCache(cache) {
|
|
274
|
+
if (!cache || !cache.writable) return;
|
|
275
|
+
const usedKeys = Object.keys(cache.used);
|
|
276
|
+
// Rewrite when something new was computed or something stale can go.
|
|
277
|
+
if (!cache.dirty && usedKeys.length === Object.keys(cache.entries).length) return;
|
|
278
|
+
const entries = {};
|
|
279
|
+
for (const key of usedKeys.slice(-CHERRY_CACHE_MAX)) entries[key] = cache.used[key];
|
|
280
|
+
try {
|
|
281
|
+
fs.mkdirSync(path.dirname(cache.file), { recursive: true });
|
|
282
|
+
const tmp = `${cache.file}.${process.pid}.tmp`;
|
|
283
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ version: 1, entries })}\n`);
|
|
284
|
+
fs.renameSync(tmp, cache.file);
|
|
285
|
+
} catch {
|
|
286
|
+
// The cache is only a speedup; a failed write costs a recompute next time.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function resolveSha(root, ref) {
|
|
291
|
+
const result = runGit(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { cwd: root, check: false });
|
|
292
|
+
return result.status === 0 ? result.stdout.trim() : '';
|
|
293
|
+
}
|
|
294
|
+
|
|
209
295
|
// Board: every branch and worktree with unlanded state, classified.
|
|
210
296
|
// landed, no commits ahead of base; the branch pointer is residue
|
|
211
297
|
// active, has unlanded commits, younger than TTL
|
|
@@ -220,6 +306,8 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
|
|
|
220
306
|
|
|
221
307
|
const branches = [];
|
|
222
308
|
const lastCommitMsByBranch = new Map();
|
|
309
|
+
const cherryCache = loadCherryCache(root);
|
|
310
|
+
const baseSha = resolveSha(root, base);
|
|
223
311
|
for (const branch of listBranches(root, base)) {
|
|
224
312
|
if (PROTECTED_BRANCHES.has(branch.name)) continue;
|
|
225
313
|
const lastCommitMs = Number(branch.lastCommitUnix) * 1000;
|
|
@@ -227,7 +315,9 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
|
|
|
227
315
|
const ahead = branch.ahead !== undefined ? branch.ahead : aheadCount(root, base, branch.name);
|
|
228
316
|
const age = ageDays(branch.lastCommitUnix, now);
|
|
229
317
|
const hours = ageHours(branch.lastCommitUnix, now);
|
|
230
|
-
const cherry = ahead > 0
|
|
318
|
+
const cherry = ahead > 0
|
|
319
|
+
? cachedCherryStats(cherryCache, root, base, baseSha, branch.name, branch.sha)
|
|
320
|
+
: { landedElsewhere: 0, unique: 0 };
|
|
231
321
|
let state = 'active';
|
|
232
322
|
if (ahead === 0 || (cherry.landedElsewhere > 0 && cherry.unique === 0)) state = 'landed';
|
|
233
323
|
else if (age > ttlDays) state = 'due';
|
|
@@ -271,7 +361,7 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
|
|
|
271
361
|
const cnt = runGit(['rev-list', '--count', `${base}..HEAD`], { cwd: wt.path, check: false });
|
|
272
362
|
ahead = cnt.status === 0 ? Number(cnt.stdout.trim()) || 0 : 0;
|
|
273
363
|
if (ahead > 0) {
|
|
274
|
-
unlandedCommits =
|
|
364
|
+
unlandedCommits = cachedCherryStats(cherryCache, wt.path, base, baseSha, 'HEAD', wt.head).unique;
|
|
275
365
|
const ts = runGit(['log', '-1', '--format=%ct', 'HEAD'], { cwd: wt.path, check: false });
|
|
276
366
|
if (ts.status === 0) ageDaysVal = ageDays(Number(ts.stdout.trim()), now);
|
|
277
367
|
if (!light) subjects = commitSubjects(wt.path, base, 'HEAD');
|
|
@@ -309,6 +399,8 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
|
|
|
309
399
|
branch.stale = branch.state === 'active' && branch.activityHours >= staleHours;
|
|
310
400
|
}
|
|
311
401
|
|
|
402
|
+
saveCherryCache(cherryCache);
|
|
403
|
+
|
|
312
404
|
const due = branches.filter((b) => b.state === 'due');
|
|
313
405
|
const landed = branches.filter((b) => b.state === 'landed');
|
|
314
406
|
const active = branches.filter((b) => b.state === 'active');
|
|
@@ -333,6 +425,33 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
|
|
|
333
425
|
};
|
|
334
426
|
}
|
|
335
427
|
|
|
428
|
+
// One landing board per process per repo. Boot, stream, and the team view
|
|
429
|
+
// each ask for the board more than once in a single command; the first call
|
|
430
|
+
// pays for git, later calls within BOARD_MEMO_MAX_AGE_MS share the result.
|
|
431
|
+
// A full board also answers light requests (light is a subset). Long-lived
|
|
432
|
+
// callers (stream follow) clear the memo before each poll.
|
|
433
|
+
const BOARD_MEMO_MAX_AGE_MS = 10000;
|
|
434
|
+
const boardMemo = new Map();
|
|
435
|
+
|
|
436
|
+
function boardMemoKey(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base = '' } = {}) {
|
|
437
|
+
return JSON.stringify([canonicalPath(root), ttlDays, staleHours, base]);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function sharedBoard(root, opts = {}) {
|
|
441
|
+
const key = boardMemoKey(root, opts);
|
|
442
|
+
const light = Boolean(opts.light);
|
|
443
|
+
const nowMs = Date.now();
|
|
444
|
+
const hit = boardMemo.get(key);
|
|
445
|
+
if (hit && nowMs - hit.at < BOARD_MEMO_MAX_AGE_MS && (light || !hit.light)) return hit.board;
|
|
446
|
+
const board = collectBoard(root, { ...opts, light });
|
|
447
|
+
boardMemo.set(key, { board, light, at: Date.now() });
|
|
448
|
+
return board;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function clearBoardMemo() {
|
|
452
|
+
boardMemo.clear();
|
|
453
|
+
}
|
|
454
|
+
|
|
336
455
|
// Counts for the boot banner and digest. This intentionally pays the same
|
|
337
456
|
// classification cost as the landing board so "in the air" never includes
|
|
338
457
|
// merged branch residue.
|
|
@@ -340,7 +459,7 @@ function landSummary(cwd = process.cwd(), ttlDays = DEFAULT_TTL_DAYS) {
|
|
|
340
459
|
const root = repoRoot(cwd);
|
|
341
460
|
if (!root || !hasCommits(root)) return null;
|
|
342
461
|
try {
|
|
343
|
-
const board =
|
|
462
|
+
const board = sharedBoard(root, { ttlDays, light: true });
|
|
344
463
|
return {
|
|
345
464
|
branches: board.summary.active + board.summary.due,
|
|
346
465
|
due: board.summary.due,
|
|
@@ -918,9 +1037,11 @@ function landCommand(args = []) {
|
|
|
918
1037
|
}
|
|
919
1038
|
|
|
920
1039
|
module.exports = {
|
|
1040
|
+
clearBoardMemo,
|
|
921
1041
|
collectBoard,
|
|
922
1042
|
countLabel,
|
|
923
1043
|
landCommand,
|
|
924
1044
|
landSummary,
|
|
925
1045
|
reap,
|
|
1046
|
+
sharedBoard,
|
|
926
1047
|
};
|