atris 3.61.0 → 3.63.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 (53) hide show
  1. package/README.md +37 -5
  2. package/atris/features/README.md +8 -0
  3. package/atris/skills/README.md +1 -0
  4. package/atris/skills/commerce/SKILL.md +96 -0
  5. package/atris/skills/engines/SKILL.md +9 -9
  6. package/bin/atris.js +12 -0
  7. package/commands/autoland.js +36 -5
  8. package/commands/autopilot.js +105 -6
  9. package/commands/buy.js +242 -0
  10. package/commands/engine.js +96 -21
  11. package/commands/host.js +56 -4
  12. package/commands/land.js +129 -8
  13. package/commands/member.js +68 -14
  14. package/commands/mission.js +116 -4
  15. package/commands/one-lap.js +14 -10
  16. package/commands/stream.js +56 -22
  17. package/commands/task.js +7 -0
  18. package/commands/team.js +82 -10
  19. package/commands/transactions.js +115 -0
  20. package/commands/worktree.js +39 -0
  21. package/commands/youtube.js +58 -26
  22. package/lib/commerce.js +75 -0
  23. package/lib/engine-ask.js +112 -9
  24. package/lib/engine-registry.js +300 -39
  25. package/lib/fleet.js +444 -26
  26. package/lib/functional-owner.js +15 -6
  27. package/lib/host.js +296 -170
  28. package/lib/known-commands.js +1 -1
  29. package/lib/member-engine.js +25 -1
  30. package/lib/member-park.js +138 -0
  31. package/lib/member-scaffold.js +39 -0
  32. package/lib/member-type.js +27 -0
  33. package/lib/roster-markdown.js +1 -1
  34. package/lib/roster-prep.js +289 -0
  35. package/lib/roster-runs.js +389 -0
  36. package/lib/roster-suggest.js +145 -0
  37. package/lib/router-brain.js +111 -3
  38. package/lib/runner-command.js +37 -2
  39. package/lib/self-drive.js +26 -2
  40. package/lib/state-detection.js +3 -3
  41. package/lib/task-db.js +38 -3
  42. package/lib/team-lineup.js +7 -2
  43. package/lib/todo.js +1 -1
  44. package/lib/wish-audit.js +1 -1
  45. package/package.json +1 -1
  46. package/scripts/det/cpu-hotspots.js +216 -0
  47. package/scripts/det/ytnotes +169 -23
  48. package/scripts/det/ytquote-repair.js +18 -4
  49. package/scripts/det/ytrail-bench.js +214 -0
  50. package/scripts/det/ytrail-eval.js +128 -56
  51. package/templates/members/host/MEMBER.md +5 -3
  52. package/templates/members/host/MISSION.md +1 -1
  53. package/templates/members/host/skills/host/SKILL.md +64 -16
@@ -38,10 +38,12 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
38
38
  efforts: CLAUDE_EFFORTS,
39
39
  }),
40
40
  // No -m or effort unless the roster pins one: codex then reads its own
41
- // ~/.codex/config.toml, which is what the roster view reports.
41
+ // ~/.codex/config.toml, which is what the roster view reports. Codex runs
42
+ // only OpenAI models, so a claude model name never reaches it.
42
43
  codex: Object.freeze({
43
44
  bin: 'codex',
44
45
  model: '',
46
+ ownModelsOnly: true,
45
47
  commandTemplate: '{bin} exec {pinnedModelFlag} {pinnedEffortFlag} {prompt}',
46
48
  effortFlag: '-c model_reasoning_effort=',
47
49
  efforts: Object.freeze(['low', 'medium', 'high', 'xhigh']),
@@ -211,6 +213,19 @@ function resolvePinnedRunnerModel(mission) {
211
213
  return explicit || runnerProfileValue('model');
212
214
  }
213
215
 
216
+ // The model an engine is launched with. An engine that runs only its own
217
+ // vendor's models drops a claude model name (a mission's stored default, a
218
+ // claude env pin) and rides its own default instead.
219
+ function runnerModelFor(engineId, model) {
220
+ const value = String(model || '').trim();
221
+ const name = String(engineId || '').trim().toLowerCase();
222
+ const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name];
223
+ if (!value || !def || !def.ownModelsOnly) return value;
224
+ // Required here, not at the top: engine-registry requires this file.
225
+ const { isClaudeFamilyModel } = require('./engine-registry');
226
+ return isClaudeFamilyModel(value) ? '' : value;
227
+ }
228
+
214
229
  function resolveClaudeRunnerBin() {
215
230
  const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
216
231
  if (env) return env;
@@ -265,6 +280,14 @@ function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model
265
280
  // The pinned flags take their leading space with them, so an unpinned
266
281
  // launch reads exactly like the template without the placeholders.
267
282
  template = template.replace(/\s*\{pinnedModelFlag\}/g, pinnedModel ? ` --model ${shellWord(pinnedModel)}` : '');
283
+ // A model dropped for an engine that cannot run it leaves no gap either,
284
+ // and a literal "--model {model}", "-m{model}" or a quoted "{model}" goes
285
+ // with it, quotes and all.
286
+ if (!model) {
287
+ template = template
288
+ .replace(/\s*\{modelFlag\}/g, '')
289
+ .replace(/\s*(?:--model|-m)(?:\s+|=)?(["']?)\{model\}\1/g, '');
290
+ }
268
291
  // Checked even when the template has no slot, so an engine that cannot
269
292
  // take effort refuses instead of running at its default.
270
293
  const effortFlag = runnerEffortFlag(effort);
@@ -299,7 +322,18 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
299
322
  const resolved = resolveClaudeRunnerModel({ model });
300
323
  const template = resolveClaudeRunnerCommandTemplate();
301
324
  if (template) {
302
- return renderRunnerCommandTemplate(template, { promptFile, allowedTools, model: resolved, pinnedModel: resolvePinnedRunnerModel({ model }), effort });
325
+ // Both the profile and the program that actually runs count: a runner
326
+ // bin named codex (or a path ending in it) is codex under any profile.
327
+ const profile = resolveRunnerProfileName();
328
+ const binName = String(resolveClaudeRunnerBin()).split(/[\\/]/).pop();
329
+ const modelFor = (value) => runnerModelFor(binName, runnerModelFor(profile, value));
330
+ return renderRunnerCommandTemplate(template, {
331
+ promptFile,
332
+ allowedTools,
333
+ model: modelFor(resolved),
334
+ pinnedModel: modelFor(resolvePinnedRunnerModel({ model })),
335
+ effort,
336
+ });
303
337
  }
304
338
  const safePath = String(promptFile).replace(/'/g, "'\\''");
305
339
  let cmd = `${shellWord(resolveClaudeRunnerBin())} -p "$(cat '${safePath}')" --model ${shellWord(resolved)}`;
@@ -339,6 +373,7 @@ module.exports = {
339
373
  resolveRunnerProfile,
340
374
  resolveClaudeRunnerModel,
341
375
  resolvePinnedRunnerModel,
376
+ runnerModelFor,
342
377
  resolveClaudeRunnerBin,
343
378
  resolveClaudeRunnerCommandTemplate,
344
379
  buildRunnerAvailabilityCommand,
package/lib/self-drive.js CHANGED
@@ -141,6 +141,8 @@ function defaultDependencies() {
141
141
  taskDb,
142
142
  resolveEngineForRole: require('./engine-registry').resolveEngineForRole,
143
143
  dispatchToEngine: require('./fleet').dispatchToEngine,
144
+ recordDispatchOutcome: require('./fleet').recordDispatchOutcome,
145
+ clock: Date.now,
144
146
  createAgentWorktree: require('../commands/worktree').createAgentWorktree,
145
147
  };
146
148
  }
@@ -272,7 +274,13 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
272
274
  agent: engine.id,
273
275
  task: `mission-blocker-${String(ref).toLowerCase()}`,
274
276
  });
275
- deps.dispatchToEngine({
277
+ const pin = {
278
+ model: engine.roster_model || '',
279
+ effort: engine.roster_effort || '',
280
+ maxSeconds: Number(engine.roster_max_seconds) || 0,
281
+ };
282
+ const startedMs = deps.clock();
283
+ const dispatched = deps.dispatchToEngine({
276
284
  task,
277
285
  engine: engine.id,
278
286
  worktreePath: worktree.path,
@@ -281,14 +289,30 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
281
289
  ...(engine.roster_effort ? { effort: engine.roster_effort } : {}),
282
290
  ...(engine.roster_max_seconds ? { maxSeconds: engine.roster_max_seconds } : {}),
283
291
  });
292
+ // How the engine did goes through the same health and run-record calls
293
+ // as every other dispatch, so a stalled blocker build benches it.
294
+ const run = dispatched && typeof dispatched === 'object' && typeof dispatched.then !== 'function'
295
+ ? deps.recordDispatchOutcome(scopedRoot, {
296
+ engine: engine.id,
297
+ pin,
298
+ startedMs,
299
+ endedMs: deps.clock(),
300
+ result: dispatched,
301
+ job: 'build',
302
+ task: ref,
303
+ source: 'mission blocker',
304
+ member: mission.owner || '',
305
+ })
306
+ : null;
284
307
  projectTasks(deps.taskDb, db, scopedRoot);
285
308
  appendEvent?.('mission_blocker_dispatched', {
286
309
  task_id: ref,
287
310
  engine: engine.id,
288
311
  blocker_class: blockerClass,
289
312
  worktree_path: worktree.path,
313
+ ...(run ? { outcome: run.outcome } : {}),
290
314
  });
291
- return { taskId: ref, dispatched: true, engine: engine.id, reason: 'dispatched' };
315
+ return { taskId: ref, dispatched: true, engine: engine.id, reason: 'dispatched', ...(run ? { outcome: run.outcome, ...(run.detail ? { detail: run.detail } : {}) } : {}) };
292
316
  } catch (error) {
293
317
  return { taskId: ref, dispatched: false, engine: engine.id, reason: error.message || String(error) };
294
318
  }
@@ -131,7 +131,7 @@ function getTasksFromDbBucket(atrisDir, bucketName) {
131
131
  const taskDb = require('./task-db');
132
132
  if (!fs.existsSync(taskDb.getDbPath())) return null;
133
133
  const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
134
- const rows = taskDb.listTasks(taskDb.open(), { workspaceRoot, limit: 500 });
134
+ const rows = taskDb.listTasksCached(taskDb.open(), { workspaceRoot, limit: 500 });
135
135
  if (!rows.length) return null;
136
136
  return rows
137
137
  .filter(row => statuses.has(String(row.status || '').toLowerCase()))
@@ -151,7 +151,7 @@ function getTaskCounts(atrisDir) {
151
151
  const taskDb = require('./task-db');
152
152
  if (fs.existsSync(taskDb.getDbPath())) {
153
153
  const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
154
- const rows = taskDb.listTasks(taskDb.open(), { workspaceRoot, limit: 500 });
154
+ const rows = taskDb.listTasksCached(taskDb.open(), { workspaceRoot, limit: 500 });
155
155
  if (rows.length) {
156
156
  const counts = { backlog: 0, active: 0, review: 0, reviewCertified: 0, source: 'db' };
157
157
  for (const row of rows) {
@@ -190,7 +190,7 @@ function getTaskGlance(atrisDir, sampleSize = 3) {
190
190
  const taskDb = require('./task-db');
191
191
  if (fs.existsSync(taskDb.getDbPath())) {
192
192
  const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
193
- const rows = taskDb.listTasks(taskDb.open(), { workspaceRoot, limit: 500 });
193
+ const rows = taskDb.listTasksCached(taskDb.open(), { workspaceRoot, limit: 500 });
194
194
  if (rows.length) {
195
195
  const glance = {
196
196
  backlog: 0, active: 0, review: 0, reviewCertified: 0,
package/lib/task-db.js CHANGED
@@ -333,10 +333,14 @@ function taskDisplayRef(row, index) {
333
333
  return `${workspaceRefPrefix(row && row.workspace_root)}-${Number(index) + 1}`;
334
334
  }
335
335
 
336
- function shortestUniqueTaskRef(id, ids, minLength = 8) {
336
+ // normalizedIds: the ids already passed through normalizeTaskRef, so a caller
337
+ // resolving every row of a list normalizes the list once, not once per row.
338
+ function shortestUniqueTaskRef(id, ids, minLength = 8, normalizedIds = null) {
337
339
  const normalized = normalizeTaskRef(id);
338
340
  if (!normalized) return '';
339
- const all = (Array.isArray(ids) ? ids : []).map(normalizeTaskRef).filter(Boolean);
341
+ const all = Array.isArray(normalizedIds)
342
+ ? normalizedIds
343
+ : (Array.isArray(ids) ? ids : []).map(normalizeTaskRef).filter(Boolean);
340
344
  for (let length = Math.min(minLength, normalized.length); length <= normalized.length; length += 1) {
341
345
  const prefix = normalized.slice(0, length);
342
346
  const matches = all.filter(candidate => candidate.startsWith(prefix));
@@ -364,10 +368,11 @@ function withTaskDisplayRefs(rows, refRows = rows) {
364
368
  const sorted = [...group]
365
369
  .sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id || '').localeCompare(String(b.id || '')))
366
370
  const ids = sorted.map(row => row && row.id);
371
+ const normalizedIds = ids.map(normalizeTaskRef).filter(Boolean);
367
372
  sorted.forEach((row, index) => {
368
373
  refs.set(row.id, {
369
374
  display_id: row.display_id || taskDisplayRef(row, index),
370
- legacy_ref: row.legacy_ref || shortestUniqueTaskRef(row.id, ids, 8),
375
+ legacy_ref: row.legacy_ref || shortestUniqueTaskRef(row.id, ids, 8, normalizedIds),
371
376
  });
372
377
  });
373
378
  }
@@ -470,6 +475,35 @@ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
470
475
  }));
471
476
  }
472
477
 
478
+ // Boot reads the same task list from several places (the TODO buckets and
479
+ // the status glance). Share one read per connection while nothing changed:
480
+ // PRAGMA data_version moves when another connection commits, and
481
+ // total_changes() moves when this connection writes. Callers must treat the
482
+ // rows as read-only.
483
+ const listTasksMemo = new WeakMap();
484
+
485
+ function listTasksCached(db, opts = {}) {
486
+ let stamp;
487
+ try {
488
+ const version = db.prepare('PRAGMA data_version').get();
489
+ const changes = db.prepare('SELECT total_changes() AS n').get();
490
+ stamp = `${Object.values(version || {})[0]}:${changes && changes.n}`;
491
+ } catch {
492
+ return listTasks(db, opts);
493
+ }
494
+ const key = JSON.stringify([opts.workspaceRoot || '', opts.status || '', opts.claimedBy || '', opts.limit || 0]);
495
+ let byKey = listTasksMemo.get(db);
496
+ if (!byKey) {
497
+ byKey = new Map();
498
+ listTasksMemo.set(db, byKey);
499
+ }
500
+ const hit = byKey.get(key);
501
+ if (hit && hit.stamp === stamp) return hit.rows;
502
+ const rows = listTasks(db, opts);
503
+ byKey.set(key, { stamp, rows });
504
+ return rows;
505
+ }
506
+
473
507
  function getTask(db, id) {
474
508
  if (!id) throw new Error('id required');
475
509
  const row = db.prepare(`
@@ -2243,6 +2277,7 @@ module.exports = {
2243
2277
  addTask,
2244
2278
  getTask,
2245
2279
  listTasks,
2280
+ listTasksCached,
2246
2281
  claimTask,
2247
2282
  releaseTask,
2248
2283
  reopenTask,
@@ -6,6 +6,7 @@
6
6
 
7
7
  const { engineRunsView, modelLabel } = require('./roster-models');
8
8
  const { rosterToolLabel } = require('./engine-registry');
9
+ const { suggestionCount } = require('./roster-suggest');
9
10
 
10
11
  const BUILT_IN_JOBS = Object.freeze(['build', 'review', 'search']);
11
12
  const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
@@ -51,6 +52,8 @@ function readLineupSafe(root = process.cwd(), now = new Date()) {
51
52
 
52
53
  // One boot line: the tool and model that runs each built-in job right now,
53
54
  // custom jobs only when all of them still fit. '' when nothing resolves.
55
+ // When the run record suggests a new order for any job, a short marker
56
+ // points at the roster view; only the end of the record is read.
54
57
  function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs = null } = {}) {
55
58
  let rows = jobs;
56
59
  if (!rows) {
@@ -65,9 +68,11 @@ function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs
65
68
  const builtIn = ordered.filter((row) => BUILT_IN_JOBS.includes(row.job) && !row.key).map(part).filter(Boolean);
66
69
  const custom = ordered.filter((row) => !BUILT_IN_JOBS.includes(row.job) || row.key).map(part).filter(Boolean);
67
70
  if (!builtIn.length && !custom.length) return '';
71
+ const count = suggestionCount(ordered, root, { now });
72
+ const marker = count ? ` · ${count} suggestion${count === 1 ? '' : 's'} (atris engine roster)` : '';
68
73
  const all = [...builtIn, ...custom].join(' · ');
69
- if (all.length <= width || !builtIn.length) return all;
70
- return builtIn.join(' · ');
74
+ if (all.length + marker.length <= width || !builtIn.length) return `${all}${marker}`;
75
+ return `${builtIn.join(' · ')}${marker}`;
71
76
  }
72
77
 
73
78
  function runsText(view) {
package/lib/todo.js CHANGED
@@ -41,7 +41,7 @@ function dbToShimRow(row) {
41
41
  function dbBuckets(workspaceRoot) {
42
42
  const taskDb = require('./task-db');
43
43
  const db = taskDb.open();
44
- const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot, limit: 500 }));
44
+ const rows = taskDb.withTaskDisplayRefs(taskDb.listTasksCached(db, { workspaceRoot, limit: 500 }));
45
45
  // Need raw source_key for merge dedup, plus the shim shape for callers.
46
46
  const backlog = [];
47
47
  const inProgress = [];
package/lib/wish-audit.js CHANGED
@@ -424,7 +424,7 @@ function policyValidator(root, excludeId = '') {
424
424
  .filter((engine) => engine.id !== String(excludeId || ''))
425
425
  .sort((a, b) => Number(a.fallback_order) - Number(b.fallback_order)
426
426
  || String(a.id).localeCompare(String(b.id)));
427
- return ranked.find((engine) => engine.health && engine.health.status === 'ready') || ranked[0] || null;
427
+ return ranked.find((engine) => engineRegistry.engineReadyAt(engine)) || ranked[0] || null;
428
428
  } catch {
429
429
  return null;
430
430
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.61.0",
3
+ "version": "3.63.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": {
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ // det/cpu-hotspots.js - where does an atris command spend its time?
3
+ // Runs one command under `node --cpu-prof` in a temp profile dir, then prints
4
+ // the total profiled ms, the top self-time hotspots, and the top call chains
5
+ // that end in spawnSync (application frames only, so node internals and
6
+ // node_modules drop out). Optional plain wall-clock runs give a median.
7
+ //
8
+ // Usage:
9
+ // node scripts/det/cpu-hotspots.js [options] -- <script.js> [args...]
10
+ // node scripts/det/cpu-hotspots.js --cwd ../repo -- bin/atris.js atris.md
11
+ // node scripts/det/cpu-hotspots.js --wall 5 --top 15 -- bin/atris.js team
12
+ //
13
+ // Options:
14
+ // --cwd <dir> working directory for the profiled command (default: here)
15
+ // --top <n> rows per table (default 10)
16
+ // --wall <n> also time n unprofiled runs and print the median wall ms
17
+ // --app <dir> frames under this dir count as application frames
18
+ // (default: the repo that holds this script)
19
+ // --keep keep the temp profile dir and print its path
20
+ // --json structured output
21
+ //
22
+ // The command's own stdout is discarded; its exit code is reported.
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+ const { spawnSync } = require('child_process');
30
+
31
+ const DEFAULT_APP_ROOT = path.resolve(__dirname, '..', '..');
32
+
33
+ function parseArgs(argv) {
34
+ const opts = { cwd: process.cwd(), top: 10, wall: 0, app: DEFAULT_APP_ROOT, keep: false, json: false, command: [] };
35
+ const sep = argv.indexOf('--');
36
+ const head = sep === -1 ? argv : argv.slice(0, sep);
37
+ opts.command = sep === -1 ? [] : argv.slice(sep + 1);
38
+ for (let i = 0; i < head.length; i += 1) {
39
+ const arg = head[i];
40
+ if (arg === '--cwd') opts.cwd = path.resolve(head[++i] || '.');
41
+ else if (arg === '--top') opts.top = Math.max(1, Number(head[++i]) || 10);
42
+ else if (arg === '--wall') opts.wall = Math.max(0, Number(head[++i]) || 0);
43
+ else if (arg === '--app') opts.app = path.resolve(head[++i] || '.');
44
+ else if (arg === '--keep') opts.keep = true;
45
+ else if (arg === '--json') opts.json = true;
46
+ else if (arg === '-h' || arg === '--help') opts.help = true;
47
+ else opts.command.push(arg);
48
+ }
49
+ return opts;
50
+ }
51
+
52
+ function frameFile(url) {
53
+ return String(url || '').replace(/^file:\/\//, '');
54
+ }
55
+
56
+ function isAppFrame(frame, appRoot) {
57
+ const file = frameFile(frame.url);
58
+ if (!file || !file.startsWith(appRoot)) return false;
59
+ return !file.includes(`${path.sep}node_modules${path.sep}`);
60
+ }
61
+
62
+ function frameLabel(frame, appRoot) {
63
+ const file = frameFile(frame.url);
64
+ const rel = appRoot && file.startsWith(appRoot) ? path.relative(appRoot, file) : (file || '(native)');
65
+ const name = frame.functionName || '(anonymous)';
66
+ return `${name} ${rel}:${Number(frame.lineNumber) + 1}`;
67
+ }
68
+
69
+ // Pure core: a parsed .cpuprofile in, tables out. Unit-testable.
70
+ function analyzeProfile(profile, { appRoot = DEFAULT_APP_ROOT, top = 10 } = {}) {
71
+ const nodes = new Map();
72
+ const parent = new Map();
73
+ for (const node of profile.nodes || []) {
74
+ nodes.set(node.id, node);
75
+ for (const child of node.children || []) parent.set(child, node.id);
76
+ }
77
+ const selfUs = new Map();
78
+ const samples = profile.samples || [];
79
+ const deltas = profile.timeDeltas || [];
80
+ for (let i = 0; i < samples.length; i += 1) {
81
+ const dt = Math.max(0, Number(deltas[i]) || 0);
82
+ selfUs.set(samples[i], (selfUs.get(samples[i]) || 0) + dt);
83
+ }
84
+ const totalUs = [...selfUs.values()].reduce((a, b) => a + b, 0)
85
+ || Math.max(0, Number(profile.endTime) - Number(profile.startTime)) || 0;
86
+
87
+ const byFrame = new Map();
88
+ for (const [id, us] of selfUs) {
89
+ const node = nodes.get(id);
90
+ if (!node) continue;
91
+ const label = frameLabel(node.callFrame || {}, appRoot);
92
+ byFrame.set(label, (byFrame.get(label) || 0) + us);
93
+ }
94
+ const hotspots = [...byFrame.entries()]
95
+ .sort((a, b) => b[1] - a[1])
96
+ .slice(0, top)
97
+ .map(([frame, us]) => ({ frame, ms: Math.round(us / 1000) }));
98
+
99
+ // Time under each spawnSync node = self time of every descendant sample.
100
+ const subtreeUs = new Map();
101
+ for (const [id, us] of selfUs) {
102
+ let cur = id;
103
+ const seen = new Set();
104
+ while (cur !== undefined && !seen.has(cur)) {
105
+ seen.add(cur);
106
+ subtreeUs.set(cur, (subtreeUs.get(cur) || 0) + us);
107
+ cur = parent.get(cur);
108
+ }
109
+ }
110
+ const chains = new Map();
111
+ for (const node of nodes.values()) {
112
+ const fn = node.callFrame && node.callFrame.functionName;
113
+ if (fn !== 'spawnSync') continue;
114
+ // Only the outermost spawnSync on a path counts (child_process wraps an
115
+ // internal spawnSync of the same name).
116
+ let up = parent.get(node.id);
117
+ let nested = false;
118
+ while (up !== undefined) {
119
+ const upNode = nodes.get(up);
120
+ if (upNode && upNode.callFrame && upNode.callFrame.functionName === 'spawnSync') { nested = true; break; }
121
+ up = parent.get(up);
122
+ }
123
+ if (nested) continue;
124
+ const us = subtreeUs.get(node.id) || 0;
125
+ if (!us) continue;
126
+ const frames = [];
127
+ let cur = parent.get(node.id);
128
+ while (cur !== undefined) {
129
+ const n = nodes.get(cur);
130
+ if (n && n.callFrame && isAppFrame(n.callFrame, appRoot)) frames.push(frameLabel(n.callFrame, appRoot));
131
+ cur = parent.get(cur);
132
+ }
133
+ const key = frames.slice(0, 6).join(' < ') || '(no app frames)';
134
+ chains.set(key, (chains.get(key) || 0) + us);
135
+ }
136
+ const spawnTotalUs = [...chains.values()].reduce((a, b) => a + b, 0);
137
+ const spawnChains = [...chains.entries()]
138
+ .sort((a, b) => b[1] - a[1])
139
+ .slice(0, top)
140
+ .map(([chain, us]) => ({ chain, ms: Math.round(us / 1000) }));
141
+ return { total_ms: Math.round(totalUs / 1000), spawn_ms: Math.round(spawnTotalUs / 1000), hotspots, spawn_chains: spawnChains };
142
+ }
143
+
144
+ function median(values) {
145
+ if (!values.length) return 0;
146
+ const sorted = [...values].sort((a, b) => a - b);
147
+ const mid = Math.floor(sorted.length / 2);
148
+ return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
149
+ }
150
+
151
+ function timeRun(command, cwd, extraArgs = []) {
152
+ const start = process.hrtime.bigint();
153
+ const result = spawnSync(process.execPath, [...extraArgs, ...command], { cwd, stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' });
154
+ const ms = Number(process.hrtime.bigint() - start) / 1e6;
155
+ return { ms: Math.round(ms), status: result.status, stderr: result.stderr || '' };
156
+ }
157
+
158
+ function resolveCommand(command) {
159
+ if (!command.length) return command;
160
+ const [script, ...rest] = command;
161
+ const abs = path.isAbsolute(script) ? script : path.resolve(process.cwd(), script);
162
+ return [fs.existsSync(abs) ? abs : script, ...rest];
163
+ }
164
+
165
+ function main() {
166
+ const opts = parseArgs(process.argv.slice(2));
167
+ if (opts.help || !opts.command.length) {
168
+ process.stderr.write('usage: node scripts/det/cpu-hotspots.js [--cwd dir] [--top n] [--wall n] [--json] -- <script.js> [args...]\n');
169
+ return opts.help ? 0 : 2;
170
+ }
171
+ const command = resolveCommand(opts.command);
172
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-cpu-prof-'));
173
+ const profiled = timeRun(command, opts.cwd, ['--cpu-prof', `--cpu-prof-dir=${dir}`]);
174
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.cpuprofile'))
175
+ .map((f) => path.join(dir, f))
176
+ .sort((a, b) => fs.statSync(b).size - fs.statSync(a).size);
177
+ if (!files.length) {
178
+ process.stderr.write(`no profile written (exit ${profiled.status})\n${profiled.stderr.slice(0, 800)}`);
179
+ return 1;
180
+ }
181
+ const report = analyzeProfile(JSON.parse(fs.readFileSync(files[0], 'utf8')), { appRoot: opts.app, top: opts.top });
182
+ report.command = command.join(' ');
183
+ report.exit_code = profiled.status;
184
+ report.profiled_wall_ms = profiled.ms;
185
+ if (opts.wall) {
186
+ const walls = [];
187
+ for (let i = 0; i < opts.wall; i += 1) walls.push(timeRun(command, opts.cwd).ms);
188
+ report.wall_runs_ms = walls;
189
+ report.wall_median_ms = median(walls);
190
+ }
191
+ if (opts.keep) report.profile_dir = dir;
192
+ else fs.rmSync(dir, { recursive: true, force: true });
193
+
194
+ if (opts.json) {
195
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
196
+ return 0;
197
+ }
198
+ const out = [];
199
+ out.push(`command: ${report.command} (exit ${report.exit_code})`);
200
+ out.push(`profiled: ${report.total_ms} ms cpu profile, ${report.profiled_wall_ms} ms wall, ${report.spawn_ms} ms in spawnSync`);
201
+ if (opts.wall) out.push(`wall median of ${opts.wall}: ${report.wall_median_ms} ms (${report.wall_runs_ms.join(', ')})`);
202
+ out.push('');
203
+ out.push('top self time:');
204
+ for (const row of report.hotspots) out.push(` ${String(row.ms).padStart(6)} ms ${row.frame}`);
205
+ out.push('');
206
+ out.push('top spawnSync chains:');
207
+ if (!report.spawn_chains.length) out.push(' (none)');
208
+ for (const row of report.spawn_chains) out.push(` ${String(row.ms).padStart(6)} ms ${row.chain}`);
209
+ if (report.profile_dir) out.push('', `profile dir: ${report.profile_dir}`);
210
+ process.stdout.write(`${out.join('\n')}\n`);
211
+ return 0;
212
+ }
213
+
214
+ if (require.main === module) process.exitCode = main();
215
+
216
+ module.exports = { analyzeProfile, median, parseArgs };