atris 3.40.0 → 3.41.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/bin/atris.js CHANGED
@@ -2732,7 +2732,7 @@ if (command === 'init') {
2732
2732
  const subcommand = process.argv[3];
2733
2733
  const args = process.argv.slice(4);
2734
2734
  require('../commands/business').businessCommand(subcommand, ...args)
2735
- .then(() => process.exit(0))
2735
+ .then(() => process.exit(process.exitCode || 0))
2736
2736
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2737
2737
  } else if (command === 'soul') {
2738
2738
  const args = process.argv.slice(3);
@@ -4384,6 +4384,7 @@ async function runComputer(argv = process.argv.slice(3), deps = {}) {
4384
4384
  default:
4385
4385
  console.error(`Unknown subcommand: ${sub}`);
4386
4386
  console.log('Run: atris computer --help');
4387
+ process.exitCode = 1;
4387
4388
  }
4388
4389
  }
4389
4390
 
@@ -4399,4 +4400,23 @@ module.exports = {
4399
4400
  extractAttachedWorkspaceMismatch,
4400
4401
  contextForAttachedWorkspaceMismatch,
4401
4402
  printRecruitingLocalSyncOutcome,
4403
+ // Hermetic parsing/formatting layer, exported for test/computer.test.js.
4404
+ parseComputerOptions,
4405
+ parseComputerCreateArgs,
4406
+ computerCreateArgsHaveName,
4407
+ normalizeComputerType,
4408
+ formatComputerTypeList,
4409
+ parseComputerDeleteArgs,
4410
+ parseComputerCardArgs,
4411
+ renderComputerCard,
4412
+ renderComputerCardMarkdown,
4413
+ formatLeaseAge,
4414
+ formatWorkspaceRef,
4415
+ workspaceMatchesInput,
4416
+ resolveWorkspaceFromList,
4417
+ workspaceMatchesComputerType,
4418
+ looksLikeWorkspaceId,
4419
+ shellQuote,
4420
+ withoutRecruitingWrapperFlags,
4421
+ formatCloudSelection,
4402
4422
  };
@@ -221,10 +221,21 @@ function parseResponseData(result) {
221
221
  try { return JSON.parse(text); } catch { return null; }
222
222
  }
223
223
 
224
+ function validationDetail(items) {
225
+ const messages = items.map((item) => {
226
+ if (!item || typeof item !== 'object') return String(item);
227
+ const message = item.msg || item.message || item.type || 'validation failed';
228
+ const location = Array.isArray(item.loc) ? item.loc.join('.') : '';
229
+ return location ? `${location}: ${message}` : message;
230
+ });
231
+ return messages.join('; ');
232
+ }
233
+
224
234
  function errorDetail(result) {
225
235
  const data = parseResponseData(result);
226
236
  if (data && typeof data === 'object') {
227
237
  const detail = data.detail || data.error || data.message;
238
+ if (Array.isArray(detail)) return validationDetail(detail);
228
239
  if (detail) return typeof detail === 'string' ? detail : JSON.stringify(detail);
229
240
  }
230
241
  return responseText(result).trim() || 'request failed';
@@ -314,9 +325,13 @@ async function uploadPages(sitesUrl, slug, pages, token, deps = {}) {
314
325
  const url = `${sitesUrl}/${slug}/pages`;
315
326
  for (let start = 0; start < pages.length; start += BATCH_SIZE) {
316
327
  const batch = pages.slice(start, start + BATCH_SIZE);
317
- const result = await requestJson('PUT', url, token, {
318
- pages: batch.map((page) => pagePayload(page, fileSystem)),
319
- }, deps);
328
+ const result = await requestJson(
329
+ 'PUT',
330
+ url,
331
+ token,
332
+ batch.map((page) => pagePayload(page, fileSystem)),
333
+ deps,
334
+ );
320
335
  if (result.status < 200 || result.status >= 300) throw requestError('PUT', url, result);
321
336
  for (const page of batch) log(` published ${page.path} (${formatBytes(page.size)})`);
322
337
  }
@@ -846,13 +861,13 @@ async function run(argv, deps = {}) {
846
861
  log(`\n deploying ${pages.length} file${pages.length === 1 ? '' : 's'} to ${liveUrl}`);
847
862
  try {
848
863
  await createSite(sitesUrl, options.name, options.spa, credentials.token, { ...deps, log });
864
+ await registerSubdomain(options.name, { ...deps, log });
849
865
  await uploadPages(sitesUrl, options.name, pages, credentials.token, { ...deps, log });
850
866
  } catch (error) {
851
867
  errorLog(` deploy failed: ${error.message}`);
852
868
  return 1;
853
869
  }
854
870
 
855
- await registerSubdomain(options.name, { ...deps, log });
856
871
  log(`\n live at ${liveUrl}`);
857
872
  return 0;
858
873
  }
package/commands/team.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
3
4
  const path = require('path');
4
5
 
5
6
  const { canonicalEngineName } = require('../lib/engine-registry');
@@ -115,12 +116,83 @@ function renderTeamRoster(roster) {
115
116
  .join('\n');
116
117
  }
117
118
 
119
+ // The pruning pass keeps the team lean like a real company: it flags members
120
+ // with no recent signal, and it never deletes anything. A signal is the newest
121
+ // of MEMBER.md, any logs/*.md, or a mission the member owns that is still
122
+ // active or running.
123
+ const PRUNE_ACTIVE_MISSION_STATUSES = new Set(['active', 'running']);
124
+ const DEFAULT_PRUNE_DAYS = 30;
125
+ const DAY_MS = 24 * 60 * 60 * 1000;
126
+
127
+ function newestSignalMs(member) {
128
+ const times = [];
129
+ const stamp = (file) => {
130
+ try { times.push(fs.statSync(file).mtimeMs); } catch { /* missing file is just no signal */ }
131
+ };
132
+ if (member?.path) stamp(member.path);
133
+ if (member?.dir) {
134
+ const logsDir = path.join(member.dir, 'logs');
135
+ let entries = [];
136
+ try { entries = fs.readdirSync(logsDir); } catch { entries = []; }
137
+ for (const entry of entries) {
138
+ if (entry.endsWith('.md')) stamp(path.join(logsDir, entry));
139
+ }
140
+ }
141
+ return times.length ? Math.max(...times) : 0;
142
+ }
143
+
144
+ function collectTeamPrune(deps = {}) {
145
+ const root = deps.root || repoRoot(deps.cwd || process.cwd());
146
+ const days = Number.isFinite(deps.days) && deps.days > 0 ? deps.days : DEFAULT_PRUNE_DAYS;
147
+ const nowMs = typeof deps.now === 'function' ? deps.now() : Date.now();
148
+ const activeOwners = new Set();
149
+ for (const mission of collectMissions(root, deps)) {
150
+ if (!PRUNE_ACTIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase())) continue;
151
+ const owner = String(mission?.owner || mission?.member || '').trim().toLowerCase();
152
+ if (owner) activeOwners.add(owner);
153
+ }
154
+ const quiet = [];
155
+ let activeCount = 0;
156
+ for (const member of collectMembers(root, deps)) {
157
+ const name = String(member?.name || '').trim().toLowerCase();
158
+ if (!name) continue;
159
+ const signalMs = newestSignalMs(member);
160
+ if (activeOwners.has(name) || (signalMs && nowMs - signalMs < days * DAY_MS)) {
161
+ activeCount += 1;
162
+ continue;
163
+ }
164
+ quiet.push({
165
+ name,
166
+ days_quiet: signalMs ? Math.floor((nowMs - signalMs) / DAY_MS) : null,
167
+ last_signal: signalMs ? new Date(signalMs).toISOString() : null,
168
+ });
169
+ }
170
+ quiet.sort((a, b) => a.name.localeCompare(b.name));
171
+ return { quiet, active_count: activeCount };
172
+ }
173
+
174
+ function renderTeamPrune(report, days = DEFAULT_PRUNE_DAYS) {
175
+ if (!report.quiet.length && !report.active_count) {
176
+ return 'no team members yet. create one with: atris member create <name> --role="..."';
177
+ }
178
+ if (!report.quiet.length) {
179
+ return `everyone on the team has a signal newer than ${days} days. nothing to prune.`;
180
+ }
181
+ const lines = report.quiet.map((entry) => (entry.days_quiet === null
182
+ ? `${entry.name} has no recorded activity; keep, hand off, or retire.`
183
+ : `${entry.name} has been quiet for ${entry.days_quiet} days; keep, hand off, or retire.`));
184
+ lines.push(`${report.active_count} member${report.active_count === 1 ? ' is' : 's are'} still active. nothing was deleted; this is a report.`);
185
+ return lines.join('\n');
186
+ }
187
+
118
188
  function helpText() {
119
189
  return [
120
190
  'atris team - one team view: every member, their role, and any engine running their work',
121
191
  'atris team presence - show who is awake and what they are doing',
192
+ 'atris team prune - flag members with no recent activity; deletes nothing',
122
193
  '',
123
194
  'usage: atris team [roster|presence] [--json]',
195
+ 'usage: atris team prune [--days N] [--json]',
124
196
  ].join('\n');
125
197
  }
126
198
 
@@ -129,6 +201,27 @@ function teamCommand(args = [], deps = {}) {
129
201
  (deps.write || process.stdout.write.bind(process.stdout))(`${helpText()}\n`);
130
202
  return 0;
131
203
  }
204
+ if (args[0] === 'prune') {
205
+ const rest = args.slice(1);
206
+ let days = DEFAULT_PRUNE_DAYS;
207
+ let json = false;
208
+ let bad = false;
209
+ for (let i = 0; i < rest.length; i += 1) {
210
+ const arg = rest[i];
211
+ if (arg === '--json') { json = true; continue; }
212
+ if (arg === '--days') { i += 1; days = Number(rest[i]); continue; }
213
+ if (arg.startsWith('--days=')) { days = Number(arg.slice('--days='.length)); continue; }
214
+ bad = true;
215
+ }
216
+ if (bad || !Number.isFinite(days) || days <= 0) {
217
+ (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team prune [--days N] [--json]\n');
218
+ return 2;
219
+ }
220
+ const report = deps.prune || collectTeamPrune({ ...deps, days });
221
+ const output = json ? JSON.stringify(report, null, 2) : renderTeamPrune(report, days);
222
+ (deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
223
+ return 0;
224
+ }
132
225
  const rosterArgs = args.filter((arg) => arg !== 'roster');
133
226
  if (args[0] !== 'presence' && rosterArgs.every((arg) => arg === '--json')) {
134
227
  const roster = deps.roster || collectTeamRoster(deps);
@@ -139,7 +232,7 @@ function teamCommand(args = [], deps = {}) {
139
232
  return 0;
140
233
  }
141
234
  if (args[0] !== 'presence' || args.some((arg, index) => index > 0 && arg !== '--json')) {
142
- (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence] [--json]\n');
235
+ (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence|prune] [--json]\n');
143
236
  return 2;
144
237
  }
145
238
  const presence = deps.presence || collectTeamPresence(deps);
@@ -150,4 +243,4 @@ function teamCommand(args = [], deps = {}) {
150
243
  return 0;
151
244
  }
152
245
 
153
- module.exports = { collectMissions, collectTasks, collectTeamPresence, collectTeamRoster, renderTeamRoster, teamCommand };
246
+ module.exports = { collectMissions, collectTasks, collectTeamPresence, collectTeamPrune, collectTeamRoster, renderTeamPrune, renderTeamRoster, teamCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.40.0",
3
+ "version": "3.41.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {