atris 3.0.1 → 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.
- package/README.md +22 -0
- package/atris/skills/endgame/SKILL.md +19 -1
- package/atris/skills/improve/SKILL.md +65 -62
- package/atris/skills/launch/SKILL.md +62 -0
- package/atris/skills/tidy/SKILL.md +84 -0
- package/bin/atris.js +2 -1
- package/commands/autopilot.js +312 -31
- package/commands/business.js +149 -32
- package/commands/sync.js +9 -5
- package/lib/scorecard.js +287 -0
- package/lib/todo.js +12 -2
- package/package.json +1 -1
package/commands/business.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
1150
|
+
atris business init "My Company" --template saas
|
|
1050
1151
|
|
|
1051
|
-
2.
|
|
1052
|
-
atris
|
|
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
|
-
|
|
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('
|
|
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 = {
|
|
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 };
|
package/lib/scorecard.js
ADDED
|
@@ -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
|
|
37
|
-
const taskMatch = line.match(/^- \*\*([A-
|
|
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);
|