atris 3.0.0 → 3.1.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.
@@ -1,7 +1,9 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const os = require('os');
3
4
  const { loadCredentials } = require('../utils/auth');
4
5
  const { apiRequestJson } = require('../utils/api');
6
+ const { syncBusinessCanonical } = require('./sync');
5
7
 
6
8
  function getBusinessConfigPath() {
7
9
  const home = require('os').homedir();
@@ -20,6 +22,86 @@ function saveBusinesses(data) {
20
22
  fs.writeFileSync(getBusinessConfigPath(), JSON.stringify(data, null, 2));
21
23
  }
22
24
 
25
+ function parseCreateBusinessFlags(flags, cwd = process.cwd()) {
26
+ const options = {
27
+ description: '',
28
+ template: null,
29
+ ownerEmail: '',
30
+ noLocal: false,
31
+ workspace: false,
32
+ here: false,
33
+ root: null,
34
+ cwd,
35
+ };
36
+
37
+ for (let i = 0; i < flags.length; i++) {
38
+ const flag = flags[i];
39
+ const next = flags[i + 1];
40
+
41
+ if ((flag === '--description' || flag === '-d') && next) {
42
+ options.description = next;
43
+ i++;
44
+ } else if ((flag === '--template' || flag === '-t') && next) {
45
+ options.template = next;
46
+ i++;
47
+ } else if (flag === '--owner-email' && next) {
48
+ options.ownerEmail = next;
49
+ i++;
50
+ } else if ((flag === '--root' || flag === '--workspace-root') && next) {
51
+ options.root = path.resolve(cwd, next);
52
+ options.workspace = true;
53
+ i++;
54
+ } else if (flag === '--here') {
55
+ options.here = true;
56
+ options.workspace = true;
57
+ } else if (flag === '--workspace' || flag === '--local-workspace') {
58
+ options.workspace = true;
59
+ } else if (flag === '--no-local') {
60
+ options.noLocal = true;
61
+ }
62
+ }
63
+
64
+ return options;
65
+ }
66
+
67
+ function resolveWorkspaceRoot(slug, options = {}) {
68
+ if (options.noLocal) return null;
69
+ if (options.here) return options.cwd || process.cwd();
70
+ if (options.root) return path.join(options.root, slug);
71
+ return path.join(os.homedir(), 'arena', 'atris-business', slug);
72
+ }
73
+
74
+ function createCanonicalBusinessWorkspace(targetRoot, bizMeta, options = {}) {
75
+ if (!targetRoot) {
76
+ throw new Error('No target directory provided for business workspace.');
77
+ }
78
+
79
+ if (options.here !== true && fs.existsSync(targetRoot) && !fs.statSync(targetRoot).isDirectory()) {
80
+ throw new Error(`Target path is not a directory: ${targetRoot}`);
81
+ }
82
+
83
+ fs.mkdirSync(targetRoot, { recursive: true });
84
+
85
+ const atrisMetaDir = path.join(targetRoot, '.atris');
86
+ const businessJsonPath = path.join(atrisMetaDir, 'business.json');
87
+ if (fs.existsSync(businessJsonPath)) {
88
+ throw new Error(`Target already contains .atris/business.json: ${targetRoot}`);
89
+ }
90
+
91
+ fs.mkdirSync(atrisMetaDir, { recursive: true });
92
+ fs.writeFileSync(businessJsonPath, JSON.stringify({
93
+ business_id: bizMeta.business_id,
94
+ workspace_id: bizMeta.workspace_id,
95
+ name: bizMeta.name,
96
+ slug: bizMeta.slug,
97
+ owner_email: bizMeta.owner_email || '',
98
+ created_at: new Date().toISOString(),
99
+ }, null, 2));
100
+
101
+ syncBusinessCanonical(targetRoot, bizMeta, { force: false, dryRun: false });
102
+ return { targetRoot, businessJsonPath };
103
+ }
104
+
23
105
  function detectBusinessSlug(explicitSlug) {
24
106
  if (explicitSlug) return explicitSlug;
25
107
  const bizFile = path.join(process.cwd(), '.atris', 'business.json');
@@ -519,9 +601,9 @@ async function businessAudit() {
519
601
  console.log('');
520
602
  }
521
603
 
522
- async function createBusiness(name, ...flags) {
604
+ async function createBusinessInternal(name, flags = [], mode = 'auto') {
523
605
  if (!name) {
524
- console.error('Usage: atris business create <name> [--description "..."]');
606
+ console.error('Usage: atris business create <name> [--description "..."] [--workspace] [--here|--root <dir>]');
525
607
  process.exit(1);
526
608
  }
527
609
 
@@ -531,14 +613,8 @@ async function createBusiness(name, ...flags) {
531
613
  process.exit(1);
532
614
  }
533
615
 
534
- // Parse flags
535
- let description = '';
536
- for (let i = 0; i < flags.length; i++) {
537
- if ((flags[i] === '--description' || flags[i] === '-d') && flags[i + 1]) {
538
- description = flags[i + 1];
539
- i++;
540
- }
541
- }
616
+ const options = parseCreateBusinessFlags(flags);
617
+ const description = options.description;
542
618
 
543
619
  console.log(`Creating business: ${name}...`);
544
620
 
@@ -567,8 +643,15 @@ async function createBusiness(name, ...flags) {
567
643
  };
568
644
  saveBusinesses(businesses);
569
645
 
570
- // Scaffold local directory if in an atris project
571
- const atrisDir = findAtrisDir();
646
+ const shouldCreateCanonicalWorkspace = !options.noLocal && (
647
+ mode === 'canonical' ||
648
+ options.workspace ||
649
+ options.here ||
650
+ Boolean(options.root)
651
+ );
652
+
653
+ // Scaffold legacy local directory if in an atris project
654
+ const atrisDir = !shouldCreateCanonicalWorkspace ? findAtrisDir() : null;
572
655
  if (atrisDir) {
573
656
  const bizDir = path.join(atrisDir, 'business', biz.slug);
574
657
  if (!fs.existsSync(bizDir)) {
@@ -584,16 +667,21 @@ async function createBusiness(name, ...flags) {
584
667
  ].join(''));
585
668
  console.log(` Local scaffold: ${bizDir}/`);
586
669
  }
587
- }
588
-
589
- // Apply template if specified
590
- let template = null;
591
- for (let i = 0; i < flags.length; i++) {
592
- if ((flags[i] === '--template' || flags[i] === '-t') && flags[i + 1]) {
593
- template = flags[i + 1];
594
- i++;
595
- }
596
- }
670
+ } else if (shouldCreateCanonicalWorkspace) {
671
+ const workspaceRoot = resolveWorkspaceRoot(biz.slug, options);
672
+ const scaffold = createCanonicalBusinessWorkspace(workspaceRoot, {
673
+ business_id: biz.id,
674
+ workspace_id: biz.workspace_id,
675
+ name: biz.name,
676
+ slug: biz.slug,
677
+ owner_email: options.ownerEmail,
678
+ }, { here: options.here });
679
+ console.log(` Local workspace: ${scaffold.targetRoot}/`);
680
+ } else if (!options.noLocal) {
681
+ console.log(' Tip: run `atris business init "<name>"` or add `--workspace` for a local canonical workspace.');
682
+ }
683
+
684
+ const template = options.template;
597
685
 
598
686
  if (template) {
599
687
  const templates = {
@@ -620,9 +708,22 @@ async function createBusiness(name, ...flags) {
620
708
  console.log(` Slug: ${biz.slug}`);
621
709
  console.log(` Agent: ${biz.agent_id || '(none)'}`);
622
710
  console.log(` Dashboard: https://atris.ai/dashboard/gm/${biz.id}`);
711
+ if (shouldCreateCanonicalWorkspace) {
712
+ const workspaceRoot = resolveWorkspaceRoot(biz.slug, options);
713
+ console.log(` Next: cd ${workspaceRoot}`);
714
+ console.log(' atris align --fix');
715
+ }
623
716
  console.log('');
624
717
  }
625
718
 
719
+ async function createBusiness(name, ...flags) {
720
+ return createBusinessInternal(name, flags, 'auto');
721
+ }
722
+
723
+ async function initBusinessWorkspace(name, ...flags) {
724
+ return createBusinessInternal(name, flags, 'canonical');
725
+ }
726
+
626
727
 
627
728
  async function businessStatus(slug) {
628
729
  const creds = loadCredentials();
@@ -1046,18 +1147,18 @@ async function quickstart() {
1046
1147
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1047
1148
 
1048
1149
  1. Create:
1049
- atris business create "My Company" --template saas
1150
+ atris business init "My Company" --template saas
1050
1151
 
1051
- 2. Connect integrations:
1052
- atris business connect slack --business my-company
1053
- atris business connect github --business my-company
1054
-
1055
- 3. Deploy:
1056
- atris business deploy my-company
1152
+ 2. Open the local workspace:
1153
+ cd ~/arena/atris-business/my-company
1057
1154
 
1058
- That's it. Your agents are live.
1155
+ 3. Push local state to cloud:
1156
+ atris align --fix
1059
1157
 
1060
1158
  Optional:
1159
+ atris business connect slack --business my-company
1160
+ atris business connect github --business my-company
1161
+
1061
1162
  atris business notify digest --business my-company
1062
1163
  (get 1 email/day instead of every notification)
1063
1164
 
@@ -1076,6 +1177,10 @@ async function businessCommand(subcommand, ...args) {
1076
1177
  case 'new':
1077
1178
  await createBusiness(args[0], ...args.slice(1));
1078
1179
  break;
1180
+ case 'init':
1181
+ case 'workspace':
1182
+ await initBusinessWorkspace(args[0], ...args.slice(1));
1183
+ break;
1079
1184
  case 'list':
1080
1185
  case 'ls': {
1081
1186
  const opts = {};
@@ -1130,7 +1235,9 @@ async function businessCommand(subcommand, ...args) {
1130
1235
  console.log('');
1131
1236
  console.log(' quickstart ← Start here! 3-command guide');
1132
1237
  console.log('');
1133
- console.log(' create <name> Create a new business (cloud + local)');
1238
+ console.log(' init <name> Create a canonical business workspace (cloud + local)');
1239
+ console.log(' workspace <name> Alias for init');
1240
+ console.log(' create <name> Create the cloud business; add --workspace for local canonical scaffold');
1134
1241
  console.log(' add <slug> Register an existing cloud business');
1135
1242
  console.log(' list Show registered businesses');
1136
1243
  console.log(' team [slug] Show members, roles, and admin access');
@@ -1144,4 +1251,14 @@ async function businessCommand(subcommand, ...args) {
1144
1251
  }
1145
1252
  }
1146
1253
 
1147
- module.exports = { businessCommand, businessHealth, businessAudit, businessTeam, loadBusinesses, saveBusinesses, getBusinessConfigPath };
1254
+ module.exports = {
1255
+ businessCommand,
1256
+ businessHealth,
1257
+ businessAudit,
1258
+ businessTeam,
1259
+ loadBusinesses,
1260
+ saveBusinesses,
1261
+ getBusinessConfigPath,
1262
+ createCanonicalBusinessWorkspace,
1263
+ initBusinessWorkspace,
1264
+ };
package/commands/sync.js CHANGED
@@ -31,7 +31,9 @@ function _substituteParams(content, params) {
31
31
  return content
32
32
  .replace(/\{\{name\}\}/g, params.name || params.slug || 'this business')
33
33
  .replace(/\{\{slug\}\}/g, params.slug || 'business')
34
- .replace(/\{\{owner_email\}\}/g, params.owner_email || '');
34
+ .replace(/\{\{owner_email\}\}/g, params.owner_email || '')
35
+ .replace(/\{\{business_id\}\}/g, params.business_id || '')
36
+ .replace(/\{\{workspace_id\}\}/g, params.workspace_id || '');
35
37
  }
36
38
 
37
39
  /**
@@ -41,14 +43,16 @@ function _substituteParams(content, params) {
41
43
  * Default: NEVER overwrites existing files (preserves customizations).
42
44
  * --force: overwrites existing canonical files (bumps to latest).
43
45
  */
44
- function syncBusinessCanonical(targetRoot, bizMeta) {
46
+ function syncBusinessCanonical(targetRoot, bizMeta, options = {}) {
45
47
  const params = {
46
48
  slug: bizMeta.slug || 'business',
47
49
  name: bizMeta.name || bizMeta.slug || 'this business',
48
50
  owner_email: bizMeta.owner_email || '',
51
+ business_id: bizMeta.business_id || '',
52
+ workspace_id: bizMeta.workspace_id || '',
49
53
  };
50
- const force = process.argv.includes('--force');
51
- const dryRun = process.argv.includes('--dry-run');
54
+ const force = options.force != null ? options.force : process.argv.includes('--force');
55
+ const dryRun = options.dryRun != null ? options.dryRun : process.argv.includes('--dry-run');
52
56
  const targetAtrisDir = path.join(targetRoot, 'atris');
53
57
 
54
58
  if (!fs.existsSync(BUSINESS_TEMPLATE_DIR)) {
@@ -567,4 +571,4 @@ function syncSkills({ silent = false } = {}) {
567
571
  return updated;
568
572
  }
569
573
 
570
- module.exports = { syncAtris, syncSkills };
574
+ module.exports = { syncAtris, syncSkills, syncBusinessCanonical };
@@ -0,0 +1,287 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { parseTodo } = require('./todo');
4
+
5
+ function parsePickedAt(value) {
6
+ if (!value) return null;
7
+ const match = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{2}:\d{2}))?/);
8
+ if (!match) return null;
9
+
10
+ const [, datePart, timePart = '00:00'] = match;
11
+ const parsed = new Date(`${datePart}T${timePart}:00`);
12
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
13
+ }
14
+
15
+ function parseTickDate(dateStr, timeLabel) {
16
+ const match = String(timeLabel || '').trim().toLowerCase().match(/^(\d{1,2}):(\d{2})(?:\s*(am|pm))?$/);
17
+ if (!match) return null;
18
+
19
+ let hours = parseInt(match[1], 10);
20
+ const minutes = parseInt(match[2], 10);
21
+ const meridiem = match[3] || null;
22
+
23
+ if (meridiem === 'pm' && hours !== 12) hours += 12;
24
+ if (meridiem === 'am' && hours === 12) hours = 0;
25
+
26
+ const parsed = new Date(`${dateStr}T00:00:00`);
27
+ parsed.setHours(hours, minutes, 0, 0);
28
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
29
+ }
30
+
31
+ function listLogFiles(atrisDir, startDate, endDate = new Date()) {
32
+ const logsDir = path.join(atrisDir, 'logs');
33
+ if (!fs.existsSync(logsDir)) return [];
34
+
35
+ const startKey = startDate.toISOString().slice(0, 10);
36
+ const endKey = endDate.toISOString().slice(0, 10);
37
+ const files = [];
38
+
39
+ for (const year of fs.readdirSync(logsDir)) {
40
+ const yearDir = path.join(logsDir, year);
41
+ let stat;
42
+ try {
43
+ stat = fs.statSync(yearDir);
44
+ } catch {
45
+ continue;
46
+ }
47
+ if (!stat.isDirectory()) continue;
48
+
49
+ for (const entry of fs.readdirSync(yearDir)) {
50
+ if (!entry.endsWith('.md')) continue;
51
+ const dateKey = entry.replace(/\.md$/, '');
52
+ if (dateKey < startKey || dateKey > endKey) continue;
53
+ files.push({ dateKey, file: path.join(yearDir, entry) });
54
+ }
55
+ }
56
+
57
+ files.sort((a, b) => a.dateKey.localeCompare(b.dateKey));
58
+ return files;
59
+ }
60
+
61
+ function readNotesSection(content) {
62
+ const match = String(content || '').match(/## Notes\n([\s\S]*?)(?=\n##\s|$)/);
63
+ return match ? match[1] : '';
64
+ }
65
+
66
+ function collectRewardStats(atrisDir, pickedAt) {
67
+ const startAt = parsePickedAt(pickedAt);
68
+ if (!startAt) {
69
+ return { totalReward: 0, totalTicks: 0, haltedTicks: 0 };
70
+ }
71
+
72
+ let totalReward = 0;
73
+ let totalTicks = 0;
74
+ let haltedTicks = 0;
75
+
76
+ for (const { dateKey, file } of listLogFiles(atrisDir, startAt)) {
77
+ const notes = readNotesSection(fs.readFileSync(file, 'utf8'));
78
+ if (!notes) continue;
79
+
80
+ const lines = notes.split('\n');
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const headerMatch = lines[i].match(/^- (\d{1,2}:\d{2}(?:\s*[ap]m)?)$/i);
83
+ if (!headerMatch) continue;
84
+
85
+ const tickAt = parseTickDate(dateKey, headerMatch[1]);
86
+ if (!tickAt || tickAt < startAt) continue;
87
+
88
+ let reward = null;
89
+ let halted = false;
90
+ let j = i + 1;
91
+ for (; j < lines.length; j++) {
92
+ const current = lines[j];
93
+ if (j > i + 1 && /^- (\d{1,2}:\d{2}(?:\s*[ap]m)?)$/i.test(current)) break;
94
+ if (current && !current.startsWith(' ')) break;
95
+
96
+ const trimmed = current.trim();
97
+ if (!trimmed) continue;
98
+ if (/^Reward:\s*-?\d+$/i.test(trimmed)) {
99
+ reward = parseInt(trimmed.replace(/^Reward:\s*/i, ''), 10);
100
+ }
101
+ if (/review flagged issues|verify failed|hit an error|stopped for a manual check/i.test(trimmed)) {
102
+ halted = true;
103
+ }
104
+ }
105
+
106
+ if (reward !== null) {
107
+ totalReward += reward;
108
+ totalTicks += 1;
109
+ if (halted) haltedTicks += 1;
110
+ }
111
+
112
+ i = j - 1;
113
+ }
114
+ }
115
+
116
+ return { totalReward, totalTicks, haltedTicks };
117
+ }
118
+
119
+ function countLessonsGenerated(atrisDir, pickedAt) {
120
+ const startAt = parsePickedAt(pickedAt);
121
+ if (!startAt) return 0;
122
+
123
+ const lessonsPath = path.join(atrisDir, 'lessons.md');
124
+ if (!fs.existsSync(lessonsPath)) return 0;
125
+
126
+ const startDateKey = startAt.toISOString().slice(0, 10);
127
+ return fs.readFileSync(lessonsPath, 'utf8')
128
+ .split('\n')
129
+ .reduce((count, line) => {
130
+ const match = line.match(/^- \*\*\[(\d{4}-\d{2}-\d{2})\]/);
131
+ return match && match[1] >= startDateKey ? count + 1 : count;
132
+ }, 0);
133
+ }
134
+
135
+ function buildScorecardData(atrisDir, { slug, pickedAt } = {}) {
136
+ if (!slug) {
137
+ throw new Error('Scorecard: slug is required');
138
+ }
139
+
140
+ const todoPath = path.join(atrisDir, 'TODO.md');
141
+ const todo = parseTodo(todoPath);
142
+ const startAt = parsePickedAt(pickedAt) || new Date();
143
+ const rewardStats = collectRewardStats(atrisDir, pickedAt);
144
+ const completedEndgame = todo.completed.filter(t => t.tag === 'endgame').length;
145
+ const activeEndgame = todo.backlog.filter(t => t.tag === 'endgame').length
146
+ + todo.inProgress.filter(t => t.tag === 'endgame').length;
147
+
148
+ return {
149
+ slug,
150
+ startDate: startAt.toISOString().slice(0, 10),
151
+ endDate: new Date().toISOString().slice(0, 10),
152
+ tasksShipped: completedEndgame,
153
+ tasksAttempted: completedEndgame + activeEndgame,
154
+ wallClockHours: Math.max(0, (Date.now() - startAt.getTime()) / (1000 * 60 * 60)),
155
+ haltRatio: rewardStats.totalTicks > 0 ? rewardStats.haltedTicks / rewardStats.totalTicks : 0,
156
+ totalReward: rewardStats.totalReward,
157
+ lessonsGenerated: countLessonsGenerated(atrisDir, pickedAt),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Write a scorecard when an endgame closes.
163
+ *
164
+ * @param {string} atrisDir - Path to atris/ directory
165
+ * @param {object} data - Scorecard data
166
+ * - slug: endgame slug (e.g., "loop-self-seeds-horizons")
167
+ * - startDate: ISO date when endgame started
168
+ * - endDate: ISO date when endgame ended (default: today)
169
+ * - tasksShipped: number of tasks completed
170
+ * - tasksAttempted: number of tasks started
171
+ * - wallClockHours: total hours (float)
172
+ * - haltRatio: fraction of ticks that halted (e.g., 0.1)
173
+ * - totalReward: sum of per-tick reward scores
174
+ * - lessonsGenerated: number of lessons appended to lessons.md
175
+ */
176
+ function writeScorecard(atrisDir, data) {
177
+ const {
178
+ slug,
179
+ startDate,
180
+ endDate = new Date().toISOString().split('T')[0],
181
+ tasksShipped = 0,
182
+ tasksAttempted = 0,
183
+ wallClockHours = 0,
184
+ haltRatio = 0,
185
+ totalReward = 0,
186
+ lessonsGenerated = 0,
187
+ } = data;
188
+
189
+ // Validate required fields
190
+ if (!slug) {
191
+ throw new Error('Scorecard: slug is required');
192
+ }
193
+
194
+ const scorecardsPath = path.join(atrisDir, 'scorecards.md');
195
+
196
+ // Ensure scorecards.md exists
197
+ if (!fs.existsSync(scorecardsPath)) {
198
+ const template = `# scorecards.md — Endgame Results\n\n> Append-only. One line per closed endgame. Records outcome metrics from the horizon.\n\n---\n\n`;
199
+ fs.writeFileSync(scorecardsPath, template, 'utf8');
200
+ }
201
+
202
+ // Format: - **[date] slug** — shipped: X/Y — wall-clock: Nh — halt: Z% — reward: total — lessons: N
203
+ const haltPercent = Math.round(haltRatio * 100);
204
+ const wallClockStr = wallClockHours < 1 ? `${Math.round(wallClockHours * 60)}m` : `${wallClockHours.toFixed(1)}h`;
205
+ const line = `- **[${endDate}] ${slug}** — shipped: ${tasksShipped}/${tasksAttempted} — wall-clock: ${wallClockStr} — halt: ${haltPercent}% — reward: ${totalReward} — lessons: ${lessonsGenerated}\n`;
206
+
207
+ // Append to file
208
+ fs.appendFileSync(scorecardsPath, line, 'utf8');
209
+ }
210
+
211
+ /**
212
+ * Detect if the current endgame in TODO.md is complete (all endgame tasks in Completed).
213
+ * Returns { complete: boolean, endgameSlug: string | null }
214
+ */
215
+ function detectEndgameCompletion(atrisDir) {
216
+ const todoPath = path.join(atrisDir, 'TODO.md');
217
+ if (!fs.existsSync(todoPath)) {
218
+ return { complete: false, endgameSlug: null };
219
+ }
220
+
221
+ const todo = parseTodo(todoPath);
222
+
223
+ // Find the current endgame section
224
+ const endgameSectionMatch = fs.readFileSync(todoPath, 'utf8')
225
+ .match(/## Endgame\n\n\*\*Slug:\*\*\s*(\S+)/);
226
+
227
+ if (!endgameSectionMatch) {
228
+ return { complete: false, endgameSlug: null };
229
+ }
230
+
231
+ const slug = endgameSectionMatch[1];
232
+
233
+ // Check if there are any endgame-tagged tasks in backlog or in-progress
234
+ const hasActiveEndgame = todo.backlog.some(t => t.tag === 'endgame')
235
+ || todo.inProgress.some(t => t.tag === 'endgame');
236
+
237
+ return {
238
+ complete: !hasActiveEndgame,
239
+ endgameSlug: slug,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Parse scorecards.md and return array of scorecard objects.
245
+ */
246
+ function readScorecards(atrisDir) {
247
+ const scorecardsPath = path.join(atrisDir, 'scorecards.md');
248
+ if (!fs.existsSync(scorecardsPath)) return [];
249
+
250
+ const content = fs.readFileSync(scorecardsPath, 'utf8');
251
+ const scorecards = [];
252
+
253
+ for (const line of content.split('\n')) {
254
+ const match = line.match(/^- \*\*\[(.+?)\]\s+(.+?)\*\*\s*—\s*shipped:\s*(\d+)\/(\d+)\s*—\s*wall-clock:\s*(.+?)\s*—\s*halt:\s*(\d+)%\s*—\s*reward:\s*(\d+)\s*—\s*lessons:\s*(\d+)$/);
255
+ if (!match) continue;
256
+
257
+ const [, endDate, slug, shipped, attempted, wallClockStr, haltPercent, reward, lessons] = match;
258
+
259
+ // Parse wall-clock back to hours
260
+ let wallClockHours = 0;
261
+ if (wallClockStr.endsWith('h')) {
262
+ wallClockHours = parseFloat(wallClockStr);
263
+ } else if (wallClockStr.endsWith('m')) {
264
+ wallClockHours = parseInt(wallClockStr) / 60;
265
+ }
266
+
267
+ scorecards.push({
268
+ endDate,
269
+ slug,
270
+ tasksShipped: parseInt(shipped),
271
+ tasksAttempted: parseInt(attempted),
272
+ wallClockHours,
273
+ haltRatio: parseInt(haltPercent) / 100,
274
+ totalReward: parseInt(reward),
275
+ lessonsGenerated: parseInt(lessons),
276
+ });
277
+ }
278
+
279
+ return scorecards;
280
+ }
281
+
282
+ module.exports = {
283
+ buildScorecardData,
284
+ writeScorecard,
285
+ readScorecards,
286
+ detectEndgameCompletion,
287
+ };
package/lib/todo.js CHANGED
@@ -33,8 +33,8 @@ function parseSection(content, sectionName) {
33
33
  const line = rawLine.trimEnd();
34
34
 
35
35
  // New task line: - **T1:** Description or - **T1a:** Description [tag] [tag]
36
- // Accepts task IDs like T1, W3b, M12c single uppercase letter, digits, optional trailing lowercase letter.
37
- const taskMatch = line.match(/^- \*\*([A-Z]\d+[a-z]?):\*\*\s*(.+)$/);
36
+ // Accepts task IDs like T1, W3b, M12c, R1, T#1 letter(s), optional symbols, digits, optional trailing letter.
37
+ const taskMatch = line.match(/^- \*\*([A-Za-z][A-Za-z0-9#]*\d[a-z]?):\*\*\s*(.+)$/);
38
38
  if (taskMatch) {
39
39
  if (current) tasks.push(current);
40
40
  // Capture ALL bracketed tags in the line, not just the last one. Endgame is priority.
@@ -47,6 +47,7 @@ function parseSection(content, sectionName) {
47
47
  tags: allTags,
48
48
  claimed: null,
49
49
  stage: null,
50
+ verify: null,
50
51
  };
51
52
  continue;
52
53
  }
@@ -60,6 +61,7 @@ function parseSection(content, sectionName) {
60
61
  tag: null,
61
62
  claimed: null,
62
63
  stage: null,
64
+ verify: null,
63
65
  });
64
66
  continue;
65
67
  }
@@ -73,6 +75,7 @@ function parseSection(content, sectionName) {
73
75
  tag: null,
74
76
  claimed: null,
75
77
  stage: null,
78
+ verify: null,
76
79
  });
77
80
  continue;
78
81
  }
@@ -92,6 +95,13 @@ function parseSection(content, sectionName) {
92
95
  current.stage = stageMatch[1].trim();
93
96
  continue;
94
97
  }
98
+
99
+ // Verify line
100
+ const verifyMatch = line.match(/\*\*Verify:\*\*\s*(.+)$/) || line.match(/Verify:\s*(.+)$/);
101
+ if (verifyMatch) {
102
+ current.verify = verifyMatch[1].trim();
103
+ continue;
104
+ }
95
105
  }
96
106
 
97
107
  if (current) tasks.push(current);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.0.0",
4
- "description": "Atris — the autonomous coding loop. Works with Claude Code, Cursor, Windsurf, Codex. Endgame-driven autopilot, plain-language output, self-improving substrate. Type one command in any folder, walk away, come back to shipped commits.",
3
+ "version": "3.1.0",
4
+ "description": "Atris — an operating system for intelligence. Integrates with any agent.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
7
7
  "atris": "bin/atris.js"