atris 3.63.0 → 3.65.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.
Files changed (81) hide show
  1. package/README.md +4 -0
  2. package/atris/skills/commerce/SKILL.md +24 -0
  3. package/atris/skills/x-search/SKILL.md +3 -1
  4. package/ax +4 -3
  5. package/bin/atris.js +28 -2
  6. package/cli/atris_code.py +25 -1
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/auth.js +79 -10
  9. package/commands/autoland.js +11 -1
  10. package/commands/browse.js +2 -2
  11. package/commands/business.js +3 -3
  12. package/commands/buy.js +3 -3
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +292 -65
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/hire.js +189 -0
  19. package/commands/improve.js +142 -8
  20. package/commands/integrations.js +4 -3
  21. package/commands/loops.js +2 -1
  22. package/commands/mail.js +163 -0
  23. package/commands/member.js +47 -9
  24. package/commands/mission.js +4 -3
  25. package/commands/pack-craft.js +2 -2
  26. package/commands/publish.js +2 -2
  27. package/commands/radar.js +3 -2
  28. package/commands/router.js +2 -2
  29. package/commands/stream.js +22 -1
  30. package/commands/task.js +84 -2
  31. package/commands/team.js +355 -67
  32. package/commands/transactions.js +3 -3
  33. package/commands/truth.js +2 -2
  34. package/commands/x-search.js +81 -10
  35. package/commands/youtube.js +2 -1
  36. package/lib/auto-accept-certified.js +83 -9
  37. package/lib/autoland.js +3 -2
  38. package/lib/ax-auto-lane.js +2 -2
  39. package/lib/brief-rules.js +11 -0
  40. package/lib/business-target.js +131 -0
  41. package/lib/chat-log-scan.js +2 -1
  42. package/lib/ci-runner.js +3 -2
  43. package/lib/codex-flight.js +2 -1
  44. package/lib/commerce.js +17 -2
  45. package/lib/deck-history.js +2 -2
  46. package/lib/deck-review.js +3 -3
  47. package/lib/design-api.js +2 -2
  48. package/lib/engine-processes.js +430 -0
  49. package/lib/engine-registry.js +101 -21
  50. package/lib/first-minute.js +2 -2
  51. package/lib/fleet.js +8 -36
  52. package/lib/git-spawn.js +5 -2
  53. package/lib/ignored-cache.js +51 -0
  54. package/lib/known-commands.js +1 -1
  55. package/lib/manifest.js +2 -2
  56. package/lib/map-refs.js +70 -4
  57. package/lib/member-identity.js +56 -0
  58. package/lib/pack-capabilities.js +2 -1
  59. package/lib/permission-grants.js +2 -2
  60. package/lib/pulse.js +5 -5
  61. package/lib/roster-models.js +11 -0
  62. package/lib/roster-runs.js +13 -2
  63. package/lib/roster-stale.js +241 -0
  64. package/lib/runner-command.js +22 -6
  65. package/lib/sandbox-pids.js +113 -0
  66. package/lib/secret-gateway.js +3 -13
  67. package/lib/state-home.js +106 -0
  68. package/lib/task-db.js +4 -3
  69. package/lib/task-prune.js +462 -0
  70. package/lib/team-lineup.js +10 -71
  71. package/lib/team-presence.js +224 -0
  72. package/lib/text-table.js +81 -0
  73. package/lib/x-post-check.js +497 -0
  74. package/package.json +1 -1
  75. package/scripts/det/data/xsearch-answer.json +24 -0
  76. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  77. package/scripts/det/xsearch-bench.js +307 -0
  78. package/scripts/det/ytnotes +61 -15
  79. package/scripts/det/ytrail-bench.js +2 -1
  80. package/utils/auth.js +3 -3
  81. package/utils/update-check.js +2 -2
@@ -4,7 +4,7 @@
4
4
  // come from the same resolver `atris engine roster` uses (jobRosterView and
5
5
  // rosterReport in commands/engine.js); this file only picks and words them.
6
6
 
7
- const { engineRunsView, modelLabel } = require('./roster-models');
7
+ const { modelLabel } = require('./roster-models');
8
8
  const { rosterToolLabel } = require('./engine-registry');
9
9
  const { suggestionCount } = require('./roster-suggest');
10
10
 
@@ -75,75 +75,6 @@ function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs
75
75
  return `${builtIn.join(' · ')}${marker}`;
76
76
  }
77
77
 
78
- function runsText(view) {
79
- return view && view.text ? view.text : 'no ready tool';
80
- }
81
-
82
- // "who does each job" lines: the tool and model running each job now, its
83
- // backup, and a short note when the router or a backup is standing in.
84
- function renderJobLines(jobs, { width = 80 } = {}) {
85
- const rows = orderJobs(jobs);
86
- if (!rows.length) return [];
87
- const labelWidth = Math.max(...rows.map((row) => row.job.length));
88
- return rows.map((row) => {
89
- const head = ` ${row.job.padEnd(labelWidth)} ${runsText(row.now_runs)}`;
90
- let note = '';
91
- if (!row.pick) note = row.like ? `, same as ${row.like}` : ', picked by the router';
92
- else if (row.lead === 'first' && row.backup_runs) note = `, backup ${row.backup_runs.text}`;
93
- else if (row.lead !== 'first') note = row.status === 'expired' ? ', first pick expired' : ', first pick not ready';
94
- const line = `${head}${note}`;
95
- return line.length <= width || row.lead !== 'first' ? line : head;
96
- });
97
- }
98
-
99
- // Names under each job, wrapped to the width. A member running something
100
- // other than its job's tool and model says what it runs.
101
- function renderMemberLines(team, jobs, { width = 80 } = {}) {
102
- const members = Array.isArray(team) ? team : [];
103
- if (!members.length) return [];
104
- const rows = orderJobs(jobs);
105
- const order = [...rows.map((row) => row.job)];
106
- for (const member of members) if (member.job && !order.includes(member.job)) order.push(member.job);
107
- const groups = order
108
- .map((job) => ({ job, row: rows.find((row) => row.job === job) || null, names: members.filter((member) => member.job === job) }))
109
- .filter((group) => group.names.length);
110
- const labelWidth = Math.max(...groups.map((group) => group.job.length));
111
- const indent = ' '.repeat(labelWidth + 4);
112
- const lines = [];
113
- for (const group of groups) {
114
- const jobText = group.row && group.row.now_runs ? group.row.now_runs.text : '';
115
- const names = group.names.map((member) => {
116
- const text = member.engine ? engineRunsView(member.engine, { model: member.model || '', effort: member.effort || '' }).text : 'no ready tool';
117
- return text === jobText ? member.member : `${member.member} on ${text}`;
118
- });
119
- let line = ` ${group.job.padEnd(labelWidth)} `;
120
- let fresh = true;
121
- for (const name of names) {
122
- const piece = fresh ? name : `, ${name}`;
123
- if (!fresh && line.length + piece.length + 1 > width) {
124
- lines.push(`${line},`);
125
- line = `${indent}${name}`;
126
- } else {
127
- line += piece;
128
- }
129
- fresh = false;
130
- }
131
- lines.push(line);
132
- }
133
- return lines;
134
- }
135
-
136
- // The block `atris team` prints before today's active and rest lists.
137
- function renderLineup(lineup, { width = 80 } = {}) {
138
- if (!lineup || !lineup.ok) return '';
139
- const jobLines = renderJobLines(lineup.jobs, { width });
140
- const memberLines = renderMemberLines(lineup.team, lineup.jobs, { width });
141
- const blocks = [];
142
- if (jobLines.length) blocks.push(['who does each job:', ...jobLines].join('\n'));
143
- if (memberLines.length) blocks.push(['who is on each job:', ...memberLines].join('\n'));
144
- return blocks.join('\n\n');
145
- }
146
-
147
78
  // Per member, for json: the job, tool, model, and whether a roster line or
148
79
  // the automatic match decided it. null for a name the lineup does not know.
149
80
  function memberLineup(lineup, name) {
@@ -161,12 +92,20 @@ function memberLineup(lineup, name) {
161
92
  };
162
93
  }
163
94
 
95
+ // The two commands under both team tables: the real way to change a job's
96
+ // worker (assign takes a job, not a member) and the real way to start a
97
+ // member working (it runs on the member's roster engine).
98
+ const TEAM_FOOTER = Object.freeze([
99
+ 'change who does a job: atris engine assign <job> <tool> --model <model>',
100
+ 'launch a member: atris member run <member> "<goal>" --minutes 30',
101
+ ]);
102
+
164
103
  const LINEUP_UNREADABLE = 'could not read who does each job, so tools and models are not shown. try: atris engine roster';
165
104
 
166
105
  module.exports = {
167
106
  LINEUP_UNREADABLE,
107
+ TEAM_FOOTER,
168
108
  bootTeamLine,
169
109
  memberLineup,
170
110
  readLineupSafe,
171
- renderLineup,
172
111
  };
@@ -262,10 +262,234 @@ function renderTeamPresence(presence) {
262
262
  return lines.join('\n');
263
263
  }
264
264
 
265
+ // --- who really worked this week ------------------------------------------
266
+ //
267
+ // "Awake" above is a 15-minute window. A member that did real work yesterday
268
+ // is not awake, but it is active. Activity reads evidence that already sits
269
+ // on disk: dated files in atris/team/<member>/logs/, commits that name the
270
+ // member, runs the roster recorded for the member, and the same stream
271
+ // events presence reads (task claims, mission receipts, worktree commits),
272
+ // over a wider window. Each piece of evidence counts as one run on its day.
273
+ // A member with evidence in the last 7 days is active; 8 to 14 days is
274
+ // quiet; nothing in 14 days is idle.
275
+
276
+ const DAY_MS = 24 * 60 * 60 * 1000;
277
+ const ACTIVE_DAYS = 7;
278
+ const IDLE_AFTER_DAYS = 14;
279
+ const FAILED_RUN_OUTCOMES = new Set(['failed', 'stalled', 'credit out']);
280
+
281
+ function localDay(ms) {
282
+ const date = new Date(ms);
283
+ const pad = (value) => String(value).padStart(2, '0');
284
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
285
+ }
286
+
287
+ // The first real YYYY-MM-DD in a log file name, as a local day ('' if none).
288
+ function logFileDay(name) {
289
+ const match = /(\d{4})-(\d{2})-(\d{2})/.exec(String(name || ''));
290
+ if (!match) return '';
291
+ const [year, month, day] = match.slice(1).map(Number);
292
+ const date = new Date(year, month - 1, day);
293
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return '';
294
+ return match[0];
295
+ }
296
+
297
+ // `git log --format=%x1e%aI%x1f%B` output, read once for the whole team.
298
+ function parseGitLog(text) {
299
+ return String(text || '').split('\x1e').map((chunk) => {
300
+ const [at, ...rest] = chunk.split('\x1f');
301
+ const message = rest.join('\x1f').trim();
302
+ const ms = timestampMs(String(at || '').trim());
303
+ if (!ms || !message) return null;
304
+ const [subject, ...body] = message.split(/\r?\n/);
305
+ return { ms, subject: subject.trim(), body: body.join('\n') };
306
+ }).filter(Boolean);
307
+ }
308
+
309
+ // Which members a commit names, and whether it is a revert. Plain words are
310
+ // not enough: member names like "notes", "sites", and "builder" also show up
311
+ // in ordinary messages and in the atris-builder co-author line. A commit
312
+ // names a member through a "Member: <name>" or "Atris-Member: <name>"
313
+ // trailer, a co-author whose display name is the member, or a subject that
314
+ // starts with "<name>:" or "<name>(scope):". A revert names whoever the
315
+ // reverted subject named.
316
+ function commitMembers(commit, known) {
317
+ const names = new Set();
318
+ const check = (value) => {
319
+ const key = String(value || '').trim().toLowerCase();
320
+ if (key && known.has(key)) names.add(key);
321
+ };
322
+ let subject = String(commit.subject || '');
323
+ const revert = /^revert\s+"(.+)"\s*$/i.exec(subject);
324
+ if (revert) subject = revert[1];
325
+ const prefix = /^([a-z0-9][a-z0-9_.-]*)(?:\([^)]*\))?:\s/i.exec(subject);
326
+ if (prefix) check(prefix[1]);
327
+ for (const line of String(commit.body || '').split(/\r?\n/)) {
328
+ const trailer = /^\s*(?:atris-)?member:\s*([a-z0-9_.-]+)\s*$/i.exec(line);
329
+ if (trailer) check(trailer[1]);
330
+ const coauthor = /^\s*co-authored-by:\s*([^<]+?)\s*</i.exec(line);
331
+ if (coauthor) check(coauthor[1]);
332
+ }
333
+ return { names: [...names], reverted: Boolean(revert) };
334
+ }
335
+
336
+ function emptyDay() {
337
+ return { runs: 0, landed: 0, failed: 0, reverted: 0, engines: {}, models: {} };
338
+ }
339
+
340
+ function topKey(counts) {
341
+ let best = '';
342
+ for (const [key, count] of Object.entries(counts || {})) {
343
+ if (!best || count > counts[best]) best = key;
344
+ }
345
+ return best || null;
346
+ }
347
+
348
+ // The busiest engine and the busiest model among that engine's own runs, so
349
+ // the pair always names something that actually ran together.
350
+ function enginePair(engines, models) {
351
+ const engine = topKey(engines);
352
+ const own = Object.fromEntries(Object.entries(models || {}).filter(([key]) => !engine || key.startsWith(`${engine}\u0000`)));
353
+ const modelKey = topKey(own);
354
+ return { engine, model: modelKey ? modelKey.split('\u0000')[1] : null };
355
+ }
356
+
357
+ // One activity row per member, from evidence gathered by the caller:
358
+ // logDays: { member: ['2026-09-28', ...] }
359
+ // commits: parsed git log rows ({ ms, subject, body })
360
+ // runs: roster run records ({ at, member, engine, model, outcome })
361
+ // events: stream events ({ agent, ms | ts, summary })
362
+ function buildMemberActivity(input = {}) {
363
+ const nowMs = timestampMs(input.nowMs ?? input.now ?? Date.now()) || Date.now();
364
+ const names = [...new Set((input.members || []).map((name) => String(name || '').trim().toLowerCase()).filter(Boolean))];
365
+ const known = new Set(names);
366
+ const today = localDay(nowMs);
367
+ const activeSince = localDay(nowMs - (ACTIVE_DAYS - 1) * DAY_MS);
368
+ const quietSince = localDay(nowMs - (IDLE_AFTER_DAYS - 1) * DAY_MS);
369
+ const byMember = new Map(names.map((name) => [name, { days: {}, last: '', evidence: { logs: 0, commits: 0, runs: 0, events: 0 } }]));
370
+ const note = (member, day, source, apply) => {
371
+ const row = byMember.get(member);
372
+ if (!row || !day || day > today) return;
373
+ if (day > row.last) row.last = day;
374
+ if (day < quietSince) return;
375
+ const bucket = row.days[day] || (row.days[day] = emptyDay());
376
+ bucket.runs += 1;
377
+ row.evidence[source] += 1;
378
+ if (apply) apply(bucket);
379
+ };
380
+
381
+ for (const [member, days] of Object.entries(input.logDays || {})) {
382
+ for (const day of days || []) note(String(member).toLowerCase(), logFileDay(day), 'logs');
383
+ }
384
+ for (const commit of input.commits || []) {
385
+ const { names: named, reverted } = commitMembers(commit, known);
386
+ for (const member of named) {
387
+ note(member, localDay(commit.ms), 'commits', (bucket) => {
388
+ if (reverted) bucket.reverted += 1;
389
+ else bucket.landed += 1;
390
+ });
391
+ }
392
+ }
393
+ for (const run of input.runs || []) {
394
+ const member = String(run.member || '').trim().toLowerCase();
395
+ const ms = timestampMs(run.at);
396
+ if (!member || !ms) continue;
397
+ note(member, localDay(ms), 'runs', (bucket) => {
398
+ if (run.outcome === 'landed') bucket.landed += 1;
399
+ else if (FAILED_RUN_OUTCOMES.has(run.outcome)) bucket.failed += 1;
400
+ if (run.engine) bucket.engines[run.engine] = (bucket.engines[run.engine] || 0) + 1;
401
+ if (run.model) bucket.models[`${run.engine || ''}\u0000${run.model}`] = (bucket.models[`${run.engine || ''}\u0000${run.model}`] || 0) + 1;
402
+ });
403
+ }
404
+ // A task or mission shows up as several stream events; one per summary
405
+ // per day counts.
406
+ const seenEvents = new Set();
407
+ for (const event of input.events || []) {
408
+ const member = memberName(event?.agent || event?.member || event?.owner).toLowerCase();
409
+ const ms = timestampMs(event?.ms || event?.ts || event?.at || event?.created_at);
410
+ if (!member || !ms || !known.has(member)) continue;
411
+ const day = localDay(ms);
412
+ const key = `${member}|${day}|${event.summary || event.event || ''}`;
413
+ if (seenEvents.has(key)) continue;
414
+ seenEvents.add(key);
415
+ note(member, day, 'events');
416
+ }
417
+
418
+ return names.map((name) => {
419
+ const row = byMember.get(name);
420
+ const week = Object.entries(row.days).filter(([day]) => day >= activeSince);
421
+ const sum = (field) => week.reduce((total, [, bucket]) => total + bucket[field], 0);
422
+ const engines = {};
423
+ const models = {};
424
+ for (const [, bucket] of week) {
425
+ for (const [key, count] of Object.entries(bucket.engines)) engines[key] = (engines[key] || 0) + count;
426
+ for (const [key, count] of Object.entries(bucket.models)) models[key] = (models[key] || 0) + count;
427
+ }
428
+ const { engine, model } = enginePair(engines, models);
429
+ const lastMs = row.last ? new Date(`${row.last}T12:00:00`).getTime() : 0;
430
+ const status = row.last >= activeSince ? 'active' : row.last >= quietSince ? 'quiet' : 'idle';
431
+ return {
432
+ name,
433
+ status,
434
+ last_active: row.last || null,
435
+ days_since: row.last ? Math.max(0, Math.round((new Date(`${today}T12:00:00`).getTime() - lastMs) / DAY_MS)) : null,
436
+ runs_7d: sum('runs'),
437
+ engine,
438
+ model,
439
+ landed: sum('landed'),
440
+ failed: sum('failed'),
441
+ reverted: sum('reverted'),
442
+ evidence: row.evidence,
443
+ days: Object.fromEntries(Object.entries(row.days).sort(([a], [b]) => a.localeCompare(b)).map(([day, bucket]) => [day, {
444
+ runs: bucket.runs,
445
+ ...enginePair(bucket.engines, bucket.models),
446
+ landed: bucket.landed,
447
+ failed: bucket.failed,
448
+ reverted: bucket.reverted,
449
+ }])),
450
+ };
451
+ });
452
+ }
453
+
454
+ // The first local day of the 7-day activity window.
455
+ function activeWindowStartDay(nowMs = Date.now()) {
456
+ return localDay((timestampMs(nowMs) || Date.now()) - (ACTIVE_DAYS - 1) * DAY_MS);
457
+ }
458
+
459
+ // One row per member per day with any evidence in the last 7 days, the
460
+ // shape the self-improvement loop reads from member_activity.jsonl.
461
+ function memberActivityRows(activity, nowMs = Date.now()) {
462
+ const since = activeWindowStartDay(nowMs);
463
+ const rows = [];
464
+ for (const member of activity || []) {
465
+ for (const [day, bucket] of Object.entries(member.days || {})) {
466
+ if (day < since || !bucket.runs) continue;
467
+ rows.push({
468
+ member: member.name,
469
+ day,
470
+ runs: bucket.runs,
471
+ engine: bucket.engine,
472
+ model: bucket.model,
473
+ landed: bucket.landed,
474
+ failed: bucket.failed,
475
+ reverted: bucket.reverted,
476
+ });
477
+ }
478
+ }
479
+ return rows.sort((a, b) => a.day.localeCompare(b.day) || a.member.localeCompare(b.member));
480
+ }
481
+
265
482
  module.exports = {
266
483
  ACTIVE_MISSION_STATES,
267
484
  ACTIVE_TASK_STATES,
268
485
  DEFAULT_FRESHNESS_WINDOW_MS,
486
+ IDLE_AFTER_DAYS,
487
+ activeWindowStartDay,
488
+ buildMemberActivity,
269
489
  buildTeamPresence,
490
+ commitMembers,
491
+ logFileDay,
492
+ memberActivityRows,
493
+ parseGitLog,
270
494
  renderTeamPresence,
271
495
  };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ // Plain aligned tables for terminal views: a header row, two spaces between
4
+ // columns, no borders or pipes. A cell may hold several lines; the extra
5
+ // lines sit under the first, in the same column. When the table is wider
6
+ // than the terminal, columns marked `clip` or `wrap` shrink (rightmost
7
+ // first): a clip column ends in "…", a wrap column carries on underneath.
8
+ // Every other column keeps its full width.
9
+
10
+ const GAP = ' ';
11
+ const ELLIPSIS = '…';
12
+ const FALLBACK_WIDTH = 100;
13
+
14
+ function terminalWidth(width) {
15
+ const value = Number(width || process.stdout.columns);
16
+ return Number.isFinite(value) && value >= 20 ? value : FALLBACK_WIDTH;
17
+ }
18
+
19
+ function clipText(text, width) {
20
+ const value = String(text);
21
+ if (value.length <= width) return value;
22
+ if (width <= 1) return ELLIPSIS.slice(0, Math.max(0, width));
23
+ return `${value.slice(0, width - 1).trimEnd()}${ELLIPSIS}`;
24
+ }
25
+
26
+ // Word-wrap one line to a width; a word longer than the width is split.
27
+ function wrapText(text, width) {
28
+ const out = [];
29
+ let line = '';
30
+ for (let word of String(text).split(' ')) {
31
+ while (word.length > width) {
32
+ if (line) { out.push(line); line = ''; }
33
+ out.push(word.slice(0, width));
34
+ word = word.slice(width);
35
+ }
36
+ const next = line ? `${line} ${word}` : word;
37
+ if (next.length > width && line) { out.push(line); line = word; } else line = next;
38
+ }
39
+ out.push(line);
40
+ return out;
41
+ }
42
+
43
+ function cellLines(value) {
44
+ if (Array.isArray(value)) {
45
+ const lines = value.filter((line) => line !== null && line !== undefined).map(String);
46
+ return lines.length ? lines : [''];
47
+ }
48
+ return [value === null || value === undefined ? '' : String(value)];
49
+ }
50
+
51
+ // columns: [{ header, clip?, min? }]; rows: arrays of cells, one per column.
52
+ // Returns the table as one string, each line trimmed at the end.
53
+ function renderTable(columns, rows, { width } = {}) {
54
+ const max = terminalWidth(width);
55
+ const cells = rows.map((row) => columns.map((_, index) => cellLines(row[index])));
56
+ const widths = columns.map((column, index) => Math.max(
57
+ String(column.header).length,
58
+ ...cells.map((row) => Math.max(...row[index].map((line) => line.length))),
59
+ ));
60
+ let over = widths.reduce((sum, value) => sum + value, 0) + GAP.length * (columns.length - 1) - max;
61
+ for (let index = columns.length - 1; index >= 0 && over > 0; index -= 1) {
62
+ if (!columns[index].clip && !columns[index].wrap) continue;
63
+ const floor = Math.max(String(columns[index].header).length, columns[index].min || 8);
64
+ const cut = Math.min(over, Math.max(0, widths[index] - floor));
65
+ widths[index] -= cut;
66
+ over -= cut;
67
+ }
68
+ const line = (parts) => parts
69
+ .map((text, index) => clipText(text, widths[index]).padEnd(index === parts.length - 1 ? 0 : widths[index]))
70
+ .join(GAP)
71
+ .trimEnd();
72
+ const out = [line(columns.map((column) => column.header))];
73
+ for (const raw of cells) {
74
+ const row = raw.map((lines, index) => (columns[index].wrap ? lines.flatMap((text) => wrapText(text, widths[index])) : lines));
75
+ const height = Math.max(...row.map((lines) => lines.length));
76
+ for (let at = 0; at < height; at += 1) out.push(line(row.map((lines) => lines[at] || '')));
77
+ }
78
+ return out.join('\n');
79
+ }
80
+
81
+ module.exports = { FALLBACK_WIDTH, clipText, renderTable, terminalWidth };