atris 3.42.0 → 3.44.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 (45) hide show
  1. package/atris/skills/design/SKILL.md +7 -1
  2. package/atris/skills/engines/SKILL.md +44 -13
  3. package/atris/team/customer-lead/MEMBER.md +45 -0
  4. package/atris/team/customer-lead/SOUL.md +33 -0
  5. package/atris/team/customer-lead/START_HERE.md +7 -0
  6. package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
  7. package/atris/team/improver/MEMBER.md +33 -0
  8. package/bin/atris.js +37 -4
  9. package/commands/autoland.js +15 -1
  10. package/commands/caretaker.js +303 -0
  11. package/commands/clean.js +76 -0
  12. package/commands/engine-watch.js +212 -0
  13. package/commands/engine.js +99 -11
  14. package/commands/founder.js +304 -0
  15. package/commands/human-missions.js +844 -0
  16. package/commands/init.js +16 -7
  17. package/commands/lesson.js +178 -4
  18. package/commands/mission.js +124 -69
  19. package/commands/slop.js +34 -3
  20. package/commands/task.js +51 -4
  21. package/commands/team.js +329 -13
  22. package/commands/verify.js +99 -6
  23. package/commands/worktree.js +119 -4
  24. package/lib/auto-accept-certified.js +302 -0
  25. package/lib/cloud-mission.js +59 -2
  26. package/lib/conductor-artifacts.js +1 -1
  27. package/lib/dispatch-scout.js +383 -0
  28. package/lib/engine-ask.js +645 -0
  29. package/lib/engine-job-lifecycle.js +65 -0
  30. package/lib/engine-receipt-sweep.js +98 -0
  31. package/lib/engine-registry.js +2 -2
  32. package/lib/engine-validate.js +374 -0
  33. package/lib/fleet.js +459 -106
  34. package/lib/known-commands.js +2 -2
  35. package/lib/lesson-ledger.js +84 -0
  36. package/lib/member-alive.js +2 -2
  37. package/lib/policy-lessons.js +70 -0
  38. package/lib/receipt-evidence.js +56 -1
  39. package/lib/runner-command.js +1 -1
  40. package/lib/secret-gateway.js +588 -0
  41. package/lib/team-presence.js +13 -1
  42. package/lib/voice-gate.js +6 -0
  43. package/lib/wish-audit.js +5 -205
  44. package/lib/wish-delegate.js +5 -2
  45. package/package.json +6 -1
@@ -41,6 +41,12 @@ const {
41
41
  setEngineHealth,
42
42
  } = require('../lib/engine-registry');
43
43
  const { FLEET_CAPABLE, runDispatchFlight } = require('../lib/fleet');
44
+ const {
45
+ buildReadOnlyEngineInvocation,
46
+ runEngineAskCommand,
47
+ } = require('../lib/engine-ask');
48
+ const { runEngineValidateCommand } = require('../lib/engine-validate');
49
+ const { runEngineWatchCommand } = require('./engine-watch');
44
50
  const { ensureValidCredentials } = require('../utils/auth');
45
51
  const { apiRequestJson } = require('../utils/api');
46
52
 
@@ -895,6 +901,7 @@ async function runEngineSeedCommand(args, root, deps = {}) {
895
901
  }
896
902
 
897
903
  function printRoster(root) {
904
+ reconcileStaleEngineProbeErrors(root);
898
905
  const list = roster(root);
899
906
  const found = list.filter((e) => e.installed).length;
900
907
  const current = resolveDefaultEngine(root);
@@ -1104,12 +1111,34 @@ function runResolveCommand(args, root) {
1104
1111
  return 0;
1105
1112
  }
1106
1113
 
1114
+ // Doctor and roster both read saved health; when a probe once failed but the
1115
+ // binary is present again, clear stale error so routing is not stuck forever.
1116
+ function reconcileStaleEngineProbeErrors(root = process.cwd()) {
1117
+ for (const engine of engineRegistryView(root)) {
1118
+ const status = engine.health && engine.health.status;
1119
+ if (status !== 'error') continue;
1120
+ if (!binInstalled(engine.bin)) continue;
1121
+ try { setEngineHealth(engine.id, 'ready', root); } catch { /* best effort */ }
1122
+ }
1123
+ }
1124
+
1107
1125
  // Doctor is the one opt-in place that probes the machine: it checks every
1108
1126
  // engine binary, reports installed state, and folds ready/not_installed flips
1109
1127
  // back into the policy file. Routing itself never probes.
1110
1128
  function runDoctorCommand(args, root) {
1111
1129
  const json = args.includes('--json');
1112
- const engines = engineDoctorReport(root);
1130
+ reconcileStaleEngineProbeErrors(root);
1131
+ const engines = engineDoctorReport(root).map((engine) => {
1132
+ if (!engine.installed) return engine;
1133
+ const status = engine.health && engine.health.status;
1134
+ if (status !== 'error') return engine;
1135
+ try {
1136
+ const updated = setEngineHealth(engine.id, 'ready', root);
1137
+ return { ...engine, health: updated.health };
1138
+ } catch {
1139
+ return engine;
1140
+ }
1141
+ });
1113
1142
  if (json) {
1114
1143
  console.log(JSON.stringify({ ok: true, engines }, null, 2));
1115
1144
  return 0;
@@ -1387,10 +1416,72 @@ function runDispatchCommand(args, root) {
1387
1416
  });
1388
1417
  }
1389
1418
 
1419
+ const TASK_ID_TOKEN = /^[a-z][a-z0-9]*-\d+$/i;
1420
+ const SHORTHAND_VALUE_FLAGS = new Set(['--model', '--concurrency', '--timeout', '--jobs', '--engine', '--engines']);
1421
+
1422
+ function shorthandPromptParts(args) {
1423
+ const parts = [];
1424
+ for (let index = 0; index < args.length; index += 1) {
1425
+ const value = String(args[index] || '');
1426
+ if (SHORTHAND_VALUE_FLAGS.has(value)) {
1427
+ index += 1;
1428
+ continue;
1429
+ }
1430
+ if (value.startsWith('--')) continue;
1431
+ parts.push(value);
1432
+ }
1433
+ return parts;
1434
+ }
1435
+
1436
+ function shorthandModelError(engine, args, root) {
1437
+ const requested = flagValue(args, '--model');
1438
+ const model = String(requested.value || '').trim();
1439
+ if (!requested.present || !model) return '';
1440
+ try {
1441
+ buildReadOnlyEngineInvocation(engine, 'check model support', model);
1442
+ return '';
1443
+ } catch (error) {
1444
+ if (!error || error.reason !== 'model_not_supported') return '';
1445
+ const examples = engineRegistryView(root).find((entry) => entry.id === engine)?.models || [];
1446
+ const known = examples.length ? examples.join(', ') : 'use the engine default';
1447
+ return `${error.message}. known-good ${engine} examples: ${known}`;
1448
+ }
1449
+ }
1450
+
1390
1451
  function engineCommand(args = [], deps = {}) {
1391
- const root = process.cwd();
1452
+ const root = deps.root || process.cwd();
1453
+ const dispatch = deps.engineDispatch || runDispatchCommand;
1454
+ if ((args[0] || '').trim() === 'ask') {
1455
+ return runEngineAskCommand(args.slice(1), root, deps.engineAsk || {});
1456
+ }
1457
+ if ((args[0] || '').trim() === 'watch') {
1458
+ return runEngineWatchCommand(args.slice(1), root, deps.engineWatch || {});
1459
+ }
1460
+ if ((args[0] || '').trim() === 'validate') {
1461
+ return runEngineValidateCommand(args.slice(1), root, deps.engineValidate || {});
1462
+ }
1392
1463
  if ((args[0] || '').trim() === 'dispatch') {
1393
- return runDispatchCommand(args.slice(1), root);
1464
+ return dispatch(args.slice(1), root);
1465
+ }
1466
+
1467
+ const requestedEngine = (args[0] || '').trim();
1468
+ if (canonicalEngineName(requestedEngine) && args.length > 1) {
1469
+ const canonical = canonicalEngineName(requestedEngine);
1470
+ const shorthandArgs = args.slice(1);
1471
+ const promptParts = shorthandPromptParts(shorthandArgs);
1472
+ if (TASK_ID_TOKEN.test(promptParts[0] || '')) {
1473
+ if (shorthandArgs.length !== 1) {
1474
+ console.error('engine shorthand: a task id cannot include prompt text or flags; pick one: ask a question or build the task');
1475
+ return 2;
1476
+ }
1477
+ return dispatch([promptParts[0], '--engine', canonical], root);
1478
+ }
1479
+ const modelError = shorthandModelError(canonical, shorthandArgs, root);
1480
+ if (modelError) {
1481
+ console.error(`engine ask: ${modelError}`);
1482
+ return 2;
1483
+ }
1484
+ return runEngineAskCommand([...shorthandArgs, '--engine', canonical], root, deps.engineAsk || {});
1394
1485
  }
1395
1486
 
1396
1487
  const json = args.includes('--json');
@@ -1452,7 +1543,8 @@ function engineCommand(args = [], deps = {}) {
1452
1543
  }
1453
1544
 
1454
1545
  if (sub === 'help') {
1455
- console.log('\n atris engine roster + current default\n atris engines --chart show the fleet as an org chart\n atris engine list --json full registry: default + engines with tier, roles, fallback, health\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> [--json]\n choose the best ready engine for navigator|executor|validator\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 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');
1546
+ console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
1547
+ console.log('\n atris engine roster + current default\n atris engines --chart show the fleet as an org chart\n atris engine list --json full registry: default + engines with tier, roles, fallback, health\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> [--json]\n choose the best ready engine for navigator|executor|validator\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 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');
1456
1548
  return 0;
1457
1549
  }
1458
1550
 
@@ -1470,13 +1562,9 @@ function engineCommand(args = [], deps = {}) {
1470
1562
  return 2;
1471
1563
  }
1472
1564
  const canonical = setEngine(sub, root);
1473
- const def = RUNNER_PROFILE_DEFS[canonical];
1474
- const installed = binInstalled(def.bin);
1475
- console.log('');
1476
- console.log(` default engine: ${canonical}`);
1477
- if (!installed) console.log(` heads up: its CLI (${def.bin}) is not installed here yet — runs will fail until it is.`);
1478
- console.log(` every mission run / autopilot / run tick now rides it. one-off: --engine <name>. undo: atris engine reset`);
1479
- console.log('');
1565
+ console.log(`default engine changed to ${canonical}`);
1566
+ console.log(`ask a question: atris engine ask "..." --engine ${canonical}`);
1567
+ console.log(`dispatch a build: atris engine dispatch <task> --engine ${canonical}`);
1480
1568
  return 0;
1481
1569
  }
1482
1570
 
@@ -0,0 +1,304 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { spawnSync } = require('node:child_process');
6
+
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+ const DEFAULT_DAYS = 28;
9
+ const TASK_PROJECTION_FILE = path.join('.atris', 'state', 'tasks.projection.json');
10
+ const SCORECARD_FILE = path.join('.atris', 'state', 'founder', 'scorecard.jsonl');
11
+
12
+ function runGit(cwd, args) {
13
+ const run = spawnSync('git', args, {
14
+ cwd,
15
+ encoding: 'utf8',
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ return {
19
+ ok: !run.error && run.status === 0,
20
+ stdout: run.stdout || '',
21
+ };
22
+ }
23
+
24
+ function workspaceRoot(cwd = process.cwd()) {
25
+ const top = runGit(cwd, ['rev-parse', '--show-toplevel']);
26
+ return top.ok && top.stdout.trim() ? path.resolve(top.stdout.trim()) : path.resolve(cwd);
27
+ }
28
+
29
+ function defaultScanRoot(cwd, currentWorkspace) {
30
+ const commonDir = runGit(cwd, ['rev-parse', '--git-common-dir']);
31
+ if (commonDir.ok && commonDir.stdout.trim()) {
32
+ const absoluteCommonDir = path.resolve(cwd, commonDir.stdout.trim());
33
+ if (path.basename(absoluteCommonDir) === '.git') {
34
+ return path.dirname(path.dirname(absoluteCommonDir));
35
+ }
36
+ }
37
+ return path.dirname(currentWorkspace);
38
+ }
39
+
40
+ function parseArgs(args = []) {
41
+ let index = args[0] === 'score' ? 1 : 0;
42
+ let root = null;
43
+ let days = DEFAULT_DAYS;
44
+
45
+ while (index < args.length) {
46
+ const arg = String(args[index]);
47
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
48
+ return { help: true, root, days };
49
+ }
50
+ if (arg === '--root' || arg.startsWith('--root=')) {
51
+ const value = arg.startsWith('--root=') ? arg.slice('--root='.length) : args[++index];
52
+ if (!value || String(value).startsWith('--')) throw new Error('--root needs a directory.');
53
+ root = String(value);
54
+ index += 1;
55
+ continue;
56
+ }
57
+ if (arg === '--days' || arg.startsWith('--days=')) {
58
+ const value = arg.startsWith('--days=') ? arg.slice('--days='.length) : args[++index];
59
+ const parsed = Number(value);
60
+ if (!Number.isInteger(parsed) || parsed < 1) throw new Error('--days must be a positive integer.');
61
+ days = parsed;
62
+ index += 1;
63
+ continue;
64
+ }
65
+ throw new Error(`unknown founder option: ${arg}`);
66
+ }
67
+
68
+ return { help: false, root, days };
69
+ }
70
+
71
+ function founderNow(env = process.env) {
72
+ const raw = String(env.ATRIS_FOUNDER_NOW || '').trim();
73
+ const value = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T23:59:59.999Z` : raw;
74
+ const now = value ? new Date(value) : new Date();
75
+ if (!Number.isFinite(now.getTime())) throw new Error('ATRIS_FOUNDER_NOW must be an ISO date.');
76
+ return now;
77
+ }
78
+
79
+ function utcDayStart(date) {
80
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
81
+ }
82
+
83
+ function dateKey(value) {
84
+ const date = value instanceof Date ? value : new Date(value);
85
+ if (!Number.isFinite(date.getTime())) return null;
86
+ return date.toISOString().slice(0, 10);
87
+ }
88
+
89
+ function historyBounds(now, days) {
90
+ const today = utcDayStart(now);
91
+ const historyStart = new Date(today.getTime() - (days - 1) * DAY_MS);
92
+ const currentWindowStart = new Date(now.getTime() - 7 * DAY_MS);
93
+ const priorWindowStart = new Date(currentWindowStart.getTime() - 7 * DAY_MS);
94
+ return {
95
+ since: historyStart < priorWindowStart ? historyStart : priorWindowStart,
96
+ until: now,
97
+ currentWindowStart,
98
+ priorWindowStart,
99
+ };
100
+ }
101
+
102
+ function defaultBranch(repoRoot) {
103
+ const remoteHead = runGit(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
104
+ if (remoteHead.ok && remoteHead.stdout.trim()) return remoteHead.stdout.trim();
105
+
106
+ for (const name of ['main', 'master']) {
107
+ if (runGit(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]).ok) return name;
108
+ }
109
+
110
+ const current = runGit(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
111
+ if (current.ok && current.stdout.trim()) return current.stdout.trim();
112
+ return runGit(repoRoot, ['rev-parse', '--verify', 'HEAD']).ok ? 'HEAD' : null;
113
+ }
114
+
115
+ function commitHistory(repoRoot, branch, bounds) {
116
+ if (!branch) return { perDay: {}, timestamps: [] };
117
+ const history = runGit(repoRoot, [
118
+ 'log',
119
+ branch,
120
+ `--since=${bounds.since.toISOString()}`,
121
+ `--until=${bounds.until.toISOString()}`,
122
+ '--format=%cI',
123
+ ]);
124
+ if (!history.ok) return { perDay: {}, timestamps: [] };
125
+
126
+ const daily = {};
127
+ const timestamps = [];
128
+ for (const line of history.stdout.split('\n')) {
129
+ const timestamp = new Date(line.trim());
130
+ if (!Number.isFinite(timestamp.getTime())) continue;
131
+ timestamps.push(timestamp);
132
+ const day = dateKey(timestamp);
133
+ if (day) daily[day] = (daily[day] || 0) + 1;
134
+ }
135
+ return {
136
+ perDay: Object.fromEntries(Object.entries(daily).sort(([left], [right]) => left.localeCompare(right))),
137
+ timestamps,
138
+ };
139
+ }
140
+
141
+ function totalInRange(timestamps, start, end, includeEnd = false) {
142
+ const startTime = start.getTime();
143
+ const endTime = end.getTime();
144
+ return timestamps.reduce((total, timestamp) => {
145
+ const time = timestamp.getTime();
146
+ const inside = time >= startTime && (includeEnd ? time <= endTime : time < endTime);
147
+ return inside ? total + 1 : total;
148
+ }, 0);
149
+ }
150
+
151
+ function discoverRepos(root) {
152
+ return fs.readdirSync(root, { withFileTypes: true })
153
+ .filter((entry) => entry.isDirectory() && entry.name !== '.agent-worktrees')
154
+ .map((entry) => path.join(root, entry.name))
155
+ .filter((repoRoot) => fs.existsSync(path.join(repoRoot, '.git')))
156
+ .sort((left, right) => path.basename(left).localeCompare(path.basename(right)));
157
+ }
158
+
159
+ function collectRepoScorecards(root, bounds) {
160
+ return discoverRepos(root).map((repoRoot) => {
161
+ const branch = defaultBranch(repoRoot);
162
+ const history = commitHistory(repoRoot, branch, bounds);
163
+ return {
164
+ repo: path.basename(repoRoot),
165
+ commitsThisWeek: totalInRange(history.timestamps, bounds.currentWindowStart, bounds.until, true),
166
+ commitsLastWeek: totalInRange(history.timestamps, bounds.priorWindowStart, bounds.currentWindowStart),
167
+ perDay: history.perDay,
168
+ };
169
+ }).sort((left, right) => (
170
+ right.commitsThisWeek - left.commitsThisWeek
171
+ || right.commitsLastWeek - left.commitsLastWeek
172
+ || left.repo.localeCompare(right.repo)
173
+ ));
174
+ }
175
+
176
+ function taskRows(parsed) {
177
+ if (Array.isArray(parsed)) return parsed;
178
+ return Array.isArray(parsed?.tasks) ? parsed.tasks : null;
179
+ }
180
+
181
+ function taskClosedAt(task) {
182
+ const direct = task?.done_at || task?.closed_at || task?.completed_at;
183
+ if (direct) return direct;
184
+ const events = Array.isArray(task?.events) ? [...task.events].reverse() : [];
185
+ const closedEvent = events.find((event) => {
186
+ const eventName = String(event?.event_type || event?.type || event?.status || '').toLowerCase();
187
+ return ['done', 'closed', 'completed'].includes(eventName);
188
+ });
189
+ return closedEvent?.created_at || closedEvent?.ts || null;
190
+ }
191
+
192
+ function readTaskScorecard(root, bounds) {
193
+ const file = path.join(root, TASK_PROJECTION_FILE);
194
+ if (!fs.existsSync(file)) return { available: false, thisWeek: null, lastWeek: null };
195
+
196
+ try {
197
+ const tasks = taskRows(JSON.parse(fs.readFileSync(file, 'utf8')));
198
+ if (!tasks) return { available: false, thisWeek: null, lastWeek: null };
199
+
200
+ const timestamps = [];
201
+ for (const task of tasks) {
202
+ const status = String(task?.status || '').trim().toLowerCase();
203
+ if (!['done', 'closed'].includes(status)) continue;
204
+ const timestamp = new Date(taskClosedAt(task));
205
+ if (Number.isFinite(timestamp.getTime())) timestamps.push(timestamp);
206
+ }
207
+
208
+ return {
209
+ available: true,
210
+ thisWeek: totalInRange(timestamps, bounds.currentWindowStart, bounds.until, true),
211
+ lastWeek: totalInRange(timestamps, bounds.priorWindowStart, bounds.currentWindowStart),
212
+ };
213
+ } catch {
214
+ return { available: false, thisWeek: null, lastWeek: null };
215
+ }
216
+ }
217
+
218
+ function percentChange(current, previous) {
219
+ if (previous === 0) return current === 0 ? 0 : 100;
220
+ return Math.round(((current - previous) / previous) * 100);
221
+ }
222
+
223
+ function buildFounderScorecard(root, { days = DEFAULT_DAYS, now = new Date() } = {}) {
224
+ const bounds = historyBounds(now, days);
225
+ const perRepo = collectRepoScorecards(root, bounds);
226
+ const commitsThisWeek = perRepo.reduce((total, repo) => total + repo.commitsThisWeek, 0);
227
+ const commitsLastWeek = perRepo.reduce((total, repo) => total + repo.commitsLastWeek, 0);
228
+ const tasks = readTaskScorecard(root, bounds);
229
+
230
+ return {
231
+ ts: now.toISOString(),
232
+ days,
233
+ commitsThisWeek,
234
+ commitsLastWeek,
235
+ slopePct: percentChange(commitsThisWeek, commitsLastWeek),
236
+ tasksThisWeek: tasks.thisWeek,
237
+ tasksLastWeek: tasks.lastWeek,
238
+ perRepo,
239
+ };
240
+ }
241
+
242
+ function appendFounderScorecard(currentWorkspace, scorecard) {
243
+ const file = path.join(currentWorkspace, SCORECARD_FILE);
244
+ fs.mkdirSync(path.dirname(file), { recursive: true });
245
+ fs.appendFileSync(file, `${JSON.stringify(scorecard)}\n`, 'utf8');
246
+ return file;
247
+ }
248
+
249
+ function plural(count, word) {
250
+ return `${count} ${word}${count === 1 ? '' : 's'}`;
251
+ }
252
+
253
+ function slopeText(value) {
254
+ return value > 0 ? `+${value}%` : `${value}%`;
255
+ }
256
+
257
+ function renderFounderScorecard(scorecard) {
258
+ const activeProjects = scorecard.perRepo.filter((repo) => repo.commitsThisWeek > 0).length;
259
+ const lines = [
260
+ `last 7 days: ${plural(scorecard.commitsThisWeek, 'commit')} landed across ${plural(activeProjects, 'project')}. prior 7 days: ${scorecard.commitsLastWeek}. slope: ${slopeText(scorecard.slopePct)}.`,
261
+ ];
262
+
263
+ for (const repo of scorecard.perRepo.filter((entry) => (
264
+ entry.commitsThisWeek > 0 || entry.commitsLastWeek > 0
265
+ )).slice(0, 5)) {
266
+ lines.push(`${repo.repo}: ${repo.commitsThisWeek} last 7 days, ${repo.commitsLastWeek} prior 7 days.`);
267
+ }
268
+
269
+ if (scorecard.tasksThisWeek === null) lines.push('no task data.');
270
+ else lines.push(`tasks closed: ${scorecard.tasksThisWeek} last 7 days, ${scorecard.tasksLastWeek} prior 7 days.`);
271
+ return lines.join('\n');
272
+ }
273
+
274
+ function showFounderHelp() {
275
+ console.log('usage: atris founder [score] [--days n] [--root dir]');
276
+ console.log('shows the last 7 days against the prior 7 days from git and task receipts.');
277
+ }
278
+
279
+ function founderCommand(args = [], options = {}) {
280
+ let parsed;
281
+ try {
282
+ parsed = parseArgs(args);
283
+ } catch (error) {
284
+ console.error(`error: ${error.message}`);
285
+ return 1;
286
+ }
287
+ if (parsed.help) {
288
+ showFounderHelp();
289
+ return 0;
290
+ }
291
+
292
+ const cwd = path.resolve(options.cwd || process.cwd());
293
+ const currentWorkspace = workspaceRoot(cwd);
294
+ const root = parsed.root
295
+ ? path.resolve(cwd, parsed.root)
296
+ : defaultScanRoot(cwd, currentWorkspace);
297
+ const now = founderNow(options.env || process.env);
298
+ const scorecard = buildFounderScorecard(root, { days: parsed.days, now });
299
+ appendFounderScorecard(currentWorkspace, scorecard);
300
+ console.log(renderFounderScorecard(scorecard));
301
+ return 0;
302
+ }
303
+
304
+ module.exports = { founderCommand };