atris 3.1.0 → 3.5.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 (54) hide show
  1. package/GETTING_STARTED.md +65 -131
  2. package/README.md +29 -4
  3. package/atris/GETTING_STARTED.md +65 -131
  4. package/atris/PERSONA.md +5 -1
  5. package/atris/atris.md +122 -153
  6. package/atris/skills/aeo/SKILL.md +117 -0
  7. package/atris/skills/atris/SKILL.md +49 -25
  8. package/atris/skills/create-member/SKILL.md +29 -9
  9. package/atris/skills/endgame/SKILL.md +9 -0
  10. package/atris/skills/improve/SKILL.md +2 -2
  11. package/atris/skills/research-search/SKILL.md +167 -0
  12. package/atris/skills/research-search/arxiv_search.py +157 -0
  13. package/atris/skills/research-search/program.md +48 -0
  14. package/atris/skills/research-search/results.tsv +6 -0
  15. package/atris/skills/research-search/scholar_search.py +154 -0
  16. package/atris/skills/tidy/SKILL.md +36 -21
  17. package/atris/team/_template/MEMBER.md +2 -0
  18. package/atris/team/validator/MEMBER.md +35 -1
  19. package/atris.md +118 -178
  20. package/bin/atris.js +37 -6
  21. package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
  22. package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
  23. package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
  24. package/cli/atris_code.py +889 -0
  25. package/cli/runtime_guard.py +693 -0
  26. package/commands/align.js +15 -0
  27. package/commands/app.js +316 -0
  28. package/commands/autopilot.js +948 -42
  29. package/commands/business.js +691 -11
  30. package/commands/computer.js +1979 -43
  31. package/commands/context-sync.js +5 -0
  32. package/commands/experiments.js +1 -1
  33. package/commands/lifecycle.js +12 -0
  34. package/commands/plugin.js +24 -0
  35. package/commands/pull.js +40 -1
  36. package/commands/push.js +44 -0
  37. package/commands/release.js +183 -0
  38. package/commands/research.js +52 -0
  39. package/commands/serve.js +1 -0
  40. package/commands/sync.js +372 -87
  41. package/commands/verify.js +53 -4
  42. package/commands/wiki.js +71 -26
  43. package/lib/file-ops.js +13 -1
  44. package/lib/journal.js +23 -0
  45. package/lib/reward-config.js +24 -0
  46. package/lib/scorecard.js +58 -6
  47. package/lib/sync-telemetry.js +59 -0
  48. package/lib/todo.js +6 -0
  49. package/lib/wiki.js +235 -60
  50. package/package.json +4 -2
  51. package/utils/api.js +19 -0
  52. package/utils/auth.js +25 -1
  53. package/utils/config.js +24 -0
  54. package/utils/update-check.js +16 -0
package/commands/wiki.js CHANGED
@@ -5,11 +5,17 @@ const { apiRequestJson } = require('../utils/api');
5
5
  const { loadBusinesses, saveBusinesses } = require('./business');
6
6
  const {
7
7
  WIKI_ROOT,
8
+ PRIVATE_WIKI_ROOT,
9
+ getWikiRoot,
8
10
  ensureWikiScaffold,
11
+ ensureContextScaffold,
9
12
  findLocalWikiDir,
13
+ stageWikiIngest,
10
14
  buildIngestPrompt,
11
15
  buildQueryPrompt,
12
16
  buildLintPrompt,
17
+ writeWikiStatus,
18
+ appendWikiLog,
13
19
  } = require('../lib/wiki');
14
20
 
15
21
  function autoDetectSlug() {
@@ -31,9 +37,14 @@ function parseCloudArgs(args) {
31
37
 
32
38
  function parseModeArgs(args) {
33
39
  const cloud = args.includes('--cloud');
40
+ const privateMode = args.includes('--private');
41
+ if (cloud && privateMode) {
42
+ console.error('Use either --cloud or --private, not both.');
43
+ process.exit(1);
44
+ }
34
45
  return {
35
- mode: cloud ? 'cloud' : 'local',
36
- args: args.filter((arg) => arg !== '--cloud' && arg !== '--local'),
46
+ mode: cloud ? 'cloud' : (privateMode ? 'private' : 'local'),
47
+ args: args.filter((arg) => arg !== '--cloud' && arg !== '--local' && arg !== '--private'),
37
48
  };
38
49
  }
39
50
 
@@ -142,10 +153,10 @@ async function runChat(business, prompt, token) {
142
153
  }
143
154
  }
144
155
 
145
- function printLocalPrompt(title, prompt, details = []) {
156
+ function printLocalPrompt(title, prompt, wikiRoot, details = []) {
146
157
  console.log('');
147
158
  console.log(title);
148
- console.log(`Target: ${WIKI_ROOT}`);
159
+ console.log(`Target: ${wikiRoot}`);
149
160
  details.forEach((detail) => console.log(detail));
150
161
  console.log('');
151
162
  console.log('Prompt for the current coding agent:');
@@ -160,11 +171,33 @@ async function wikiIngest(mode, slug, sourceValue) {
160
171
  process.exit(1);
161
172
  }
162
173
 
163
- if (mode === 'local') {
164
- const wikiDir = ensureWikiScaffold();
165
- printLocalPrompt('Local wiki ingest', buildIngestPrompt(sourceValue), [
174
+ if (mode === 'local' || mode === 'private') {
175
+ const wikiMode = mode === 'private' ? 'private' : 'public';
176
+ const wikiDir = ensureWikiScaffold(process.cwd(), wikiMode);
177
+ const contextDir = ensureContextScaffold(process.cwd(), wikiMode);
178
+ const staged = stageWikiIngest(process.cwd(), sourceValue, wikiMode);
179
+ writeWikiStatus(process.cwd(), {
180
+ health: `ingest staged from ${staged.packPath}`,
181
+ nextMove: `compile ${staged.promptSource} into ${getWikiRoot(wikiMode)}`,
182
+ }, wikiMode, { lastIngest: staged.manifest.ingested_at });
183
+ appendWikiLog(
184
+ process.cwd(),
185
+ `${staged.manifest.entries.length} source item(s) staged from ${sourceValue}`,
186
+ [
187
+ `context ${contextDir}`,
188
+ `pack ${staged.packPath}`,
189
+ `manifest ${staged.manifestPath}`,
190
+ ...staged.manifest.entries.map((entry) => `${entry.kind} ${entry.staged}`),
191
+ ],
192
+ wikiMode,
193
+ 'INGEST'
194
+ );
195
+ printLocalPrompt(mode === 'private' ? 'Private wiki ingest' : 'Local wiki ingest', buildIngestPrompt(staged.promptSource, wikiMode), getWikiRoot(wikiMode), [
166
196
  `Wiki dir: ${wikiDir}`,
167
- `Sources: ${sourceValue}`,
197
+ `Context dir: ${contextDir}`,
198
+ `Pack: ${staged.packPath}`,
199
+ `Manifest: ${staged.manifestPath}`,
200
+ `Sources: ${staged.promptSource}`,
168
201
  ]);
169
202
  return;
170
203
  }
@@ -182,13 +215,14 @@ async function wikiQuery(mode, slug, question) {
182
215
  process.exit(1);
183
216
  }
184
217
 
185
- if (mode === 'local') {
186
- const wikiDir = findLocalWikiDir(process.cwd(), slug);
218
+ if (mode !== 'cloud') {
219
+ const wikiMode = mode === 'private' ? 'private' : 'public';
220
+ const wikiDir = findLocalWikiDir(process.cwd(), slug, wikiMode);
187
221
  if (!wikiDir) {
188
- console.error('No local atris/wiki found. Run: atris ingest <path>');
222
+ console.error(`No local wiki found at ${getWikiRoot(wikiMode)}. Run: atris wiki ingest${wikiMode === 'private' ? ' --private' : ''} <path>`);
189
223
  process.exit(1);
190
224
  }
191
- printLocalPrompt('Local wiki query', buildQueryPrompt(question), [
225
+ printLocalPrompt(mode === 'private' ? 'Private wiki query' : 'Local wiki query', buildQueryPrompt(question, wikiMode), getWikiRoot(wikiMode), [
192
226
  `Wiki dir: ${wikiDir}`,
193
227
  `Question: ${question}`,
194
228
  ]);
@@ -201,13 +235,14 @@ async function wikiQuery(mode, slug, question) {
201
235
  }
202
236
 
203
237
  async function wikiLint(mode, slug) {
204
- if (mode === 'local') {
205
- const wikiDir = findLocalWikiDir(process.cwd(), slug);
238
+ if (mode !== 'cloud') {
239
+ const wikiMode = mode === 'private' ? 'private' : 'public';
240
+ const wikiDir = findLocalWikiDir(process.cwd(), slug, wikiMode);
206
241
  if (!wikiDir) {
207
- console.error('No local atris/wiki found. Run: atris ingest <path>');
242
+ console.error(`No local wiki found at ${getWikiRoot(wikiMode)}. Run: atris wiki ingest${wikiMode === 'private' ? ' --private' : ''} <path>`);
208
243
  process.exit(1);
209
244
  }
210
- printLocalPrompt('Local wiki lint', buildLintPrompt(), [`Wiki dir: ${wikiDir}`]);
245
+ printLocalPrompt(mode === 'private' ? 'Private wiki lint' : 'Local wiki lint', buildLintPrompt(wikiMode), getWikiRoot(wikiMode), [`Wiki dir: ${wikiDir}`]);
211
246
  return;
212
247
  }
213
248
 
@@ -217,15 +252,16 @@ async function wikiLint(mode, slug) {
217
252
  await runChat(business, buildLintPrompt(), creds.token);
218
253
  }
219
254
 
220
- function wikiSearch(slug, query) {
255
+ function wikiSearch(mode, slug, query) {
221
256
  if (!query) {
222
257
  console.error('Usage: atris wiki search [business] <term>');
223
258
  process.exit(1);
224
259
  }
225
260
 
226
- const wikiDir = findLocalWikiDir(process.cwd(), slug);
261
+ const wikiMode = mode === 'private' ? 'private' : 'public';
262
+ const wikiDir = findLocalWikiDir(process.cwd(), slug, wikiMode);
227
263
  if (!wikiDir) {
228
- console.error(`No local wiki found. Run: atris pull ${slug || ''} --only wiki`);
264
+ console.error(`No local wiki found at ${getWikiRoot(wikiMode)}.`);
229
265
  process.exit(1);
230
266
  }
231
267
 
@@ -250,10 +286,11 @@ function wikiSearch(slug, query) {
250
286
  console.log('');
251
287
  }
252
288
 
253
- function wikiLog(slug, limit) {
254
- const wikiDir = findLocalWikiDir(process.cwd(), slug);
289
+ function wikiLog(mode, slug, limit) {
290
+ const wikiMode = mode === 'private' ? 'private' : 'public';
291
+ const wikiDir = findLocalWikiDir(process.cwd(), slug, wikiMode);
255
292
  if (!wikiDir) {
256
- console.error(`No local wiki found. Run: atris pull ${slug || ''} --only wiki`);
293
+ console.error(`No local wiki found at ${getWikiRoot(wikiMode)}.`);
257
294
  process.exit(1);
258
295
  }
259
296
 
@@ -314,14 +351,21 @@ async function wikiCommand(subcommand, ...args) {
314
351
  break;
315
352
  }
316
353
  case 'search': {
317
- const [slug, query] = parseCloudArgs(cleanArgs);
318
- wikiSearch(slug, query);
354
+ if (mode === 'private') {
355
+ wikiSearch(mode, null, cleanArgs.join(' '));
356
+ } else {
357
+ const [slug, query] = parseCloudArgs(cleanArgs);
358
+ wikiSearch(mode, slug, query);
359
+ }
319
360
  break;
320
361
  }
321
362
  case 'log': {
322
363
  let slug;
323
364
  let limit;
324
- if (cleanArgs.length === 0) {
365
+ if (mode === 'private') {
366
+ slug = null;
367
+ limit = parseInt(cleanArgs[0], 10) || 20;
368
+ } else if (cleanArgs.length === 0) {
325
369
  slug = autoDetectSlug();
326
370
  limit = 20;
327
371
  } else if (cleanArgs.length === 1) {
@@ -336,7 +380,7 @@ async function wikiCommand(subcommand, ...args) {
336
380
  slug = cleanArgs[0];
337
381
  limit = parseInt(cleanArgs[1], 10) || 20;
338
382
  }
339
- wikiLog(slug, limit);
383
+ wikiLog(mode, slug, limit);
340
384
  break;
341
385
  }
342
386
  case 'loop': {
@@ -361,6 +405,7 @@ async function wikiCommand(subcommand, ...args) {
361
405
  console.log('Flags:');
362
406
  console.log(' --cloud Route ingest/query/lint to the cloud workspace');
363
407
  console.log(' --local Be explicit about local mode');
408
+ console.log(` --private Use local private wiki at ${PRIVATE_WIKI_ROOT}/`);
364
409
  console.log('');
365
410
  console.log('Business is auto-detected from .atris/business.json for cloud mode if omitted.');
366
411
  }
package/lib/file-ops.js CHANGED
@@ -1,7 +1,11 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
- // Log file operations
4
+ /**
5
+ * Get the path components for a journal log file.
6
+ * @param {string} [dateStr] - Optional date string (defaults to today)
7
+ * @returns {Object} Object with logsDir, yearDir, logFile, dateFormatted
8
+ */
5
9
  function getLogPath(dateStr) {
6
10
  const targetDir = path.join(process.cwd(), 'atris');
7
11
  const date = dateStr ? new Date(dateStr) : new Date();
@@ -17,6 +21,9 @@ function getLogPath(dateStr) {
17
21
  return { logsDir, yearDir, logFile, dateFormatted };
18
22
  }
19
23
 
24
+ /**
25
+ * Ensure the logs directory structure exists.
26
+ */
20
27
  function ensureLogDirectory() {
21
28
  const { logsDir, yearDir } = getLogPath();
22
29
 
@@ -29,6 +36,11 @@ function ensureLogDirectory() {
29
36
  }
30
37
  }
31
38
 
39
+ /**
40
+ * Create a new daily log file, carrying forward unfinished items from yesterday.
41
+ * @param {string} logFile - Path to the log file to create
42
+ * @param {string} dateFormatted - Date string in YYYY-MM-DD format
43
+ */
32
44
  function createLogFile(logFile, dateFormatted) {
33
45
  let carryInProgress = '';
34
46
  let carryBacklog = '';
package/lib/journal.js CHANGED
@@ -4,6 +4,12 @@ const path = require('path');
4
4
  const os = require('os');
5
5
  const { spawnSync } = require('child_process');
6
6
 
7
+ /**
8
+ * Check if two timestamps are effectively the same (within 5ms).
9
+ * @param {string} a - First timestamp
10
+ * @param {string} b - Second timestamp
11
+ * @returns {boolean} True if timestamps are within 5ms of each other
12
+ */
7
13
  function isSameTimestamp(a, b) {
8
14
  if (!a || !b) return false;
9
15
  const ta = new Date(a).getTime();
@@ -12,6 +18,11 @@ function isSameTimestamp(a, b) {
12
18
  return Math.abs(ta - tb) < 5;
13
19
  }
14
20
 
21
+ /**
22
+ * Compute a SHA-256 hash of content with normalized line endings.
23
+ * @param {string} content - Content to hash
24
+ * @returns {string|null} Hex hash or null if invalid input
25
+ */
15
26
  function computeContentHash(content) {
16
27
  if (typeof content !== 'string') {
17
28
  return null;
@@ -20,6 +31,11 @@ function computeContentHash(content) {
20
31
  return crypto.createHash('sha256').update(normalized).digest('hex');
21
32
  }
22
33
 
34
+ /**
35
+ * Parse journal content into named sections (by ## headers).
36
+ * @param {string} content - Journal markdown content
37
+ * @returns {Object} Map of section name to content
38
+ */
23
39
  function parseJournalSections(content) {
24
40
  const sections = {};
25
41
  const lines = content.split('\n');
@@ -48,6 +64,13 @@ function parseJournalSections(content) {
48
64
  return sections;
49
65
  }
50
66
 
67
+ /**
68
+ * Merge local and remote journal sections, detecting conflicts.
69
+ * @param {Object} localSections - Local section map
70
+ * @param {Object} remoteSections - Remote section map
71
+ * @param {string} knownRemoteHash - Hash of remote at last sync
72
+ * @returns {{merged: Object, conflicts: Array}} Merged sections and conflicts
73
+ */
51
74
  function mergeSections(localSections, remoteSections, knownRemoteHash) {
52
75
  const merged = {};
53
76
  const conflicts = [];
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Frozen reward constants for the RL loop.
3
+ *
4
+ * These live outside mutable repo state so the loop cannot edit its own
5
+ * judge. REWARD_CHECKSUM is the SHA-256 of JSON.stringify(REWARD_CONFIG)
6
+ * + computeTickReward.toString() at ship time — if the config values or
7
+ * function body change, verifyJudgeIntegrity() halts the next tick.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+
12
+ const REWARD_CONFIG = Object.freeze({
13
+ REVIEW_CLEAN: 1,
14
+ VERIFY_PASS: 3,
15
+ NPM_TEST_BONUS: 2,
16
+ COMMIT_LANDED: 1,
17
+ HALT_PENALTY: -3,
18
+ });
19
+
20
+ // SHA-256 of JSON.stringify(REWARD_CONFIG) + computeTickReward.toString() at ship time.
21
+ // Regenerate: node -e "const c=require('./lib/reward-config');const h=require('crypto').createHash('sha256');h.update(JSON.stringify(c.REWARD_CONFIG));h.update(require('./commands/autopilot').computeTickReward.toString());console.log(h.digest('hex'))"
22
+ const REWARD_CHECKSUM = '5a84be0f7f392d6ef05337be0776f864852e94d6391da0b41486298555595a40';
23
+
24
+ module.exports = { REWARD_CONFIG, REWARD_CHECKSUM };
package/lib/scorecard.js CHANGED
@@ -2,6 +2,33 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { parseTodo } = require('./todo');
4
4
 
5
+ const PRIVATE_MEMORY_ROOT = '.atris/presidio';
6
+
7
+ /**
8
+ * Ensure the private memory directory exists.
9
+ * @param {string} atrisDir - Path to the atris directory
10
+ * @returns {string} Path to the private memory directory
11
+ */
12
+ function ensurePrivateMemoryDir(atrisDir) {
13
+ const privateDir = path.join(path.dirname(atrisDir), PRIVATE_MEMORY_ROOT);
14
+ fs.mkdirSync(privateDir, { recursive: true });
15
+ return privateDir;
16
+ }
17
+
18
+ /**
19
+ * Get the path to the scorecards file.
20
+ * @param {string} atrisDir - Path to the atris directory
21
+ * @returns {string} Path to scorecards.md
22
+ */
23
+ function getScorecardsPath(atrisDir) {
24
+ return path.join(ensurePrivateMemoryDir(atrisDir), 'scorecards.md');
25
+ }
26
+
27
+ /**
28
+ * Parse a "picked at" timestamp from scorecard data.
29
+ * @param {string} value - Timestamp string in YYYY-MM-DD [HH:MM] format
30
+ * @returns {Date|null} Parsed date or null if invalid
31
+ */
5
32
  function parsePickedAt(value) {
6
33
  if (!value) return null;
7
34
  const match = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{2}:\d{2}))?/);
@@ -12,6 +39,12 @@ function parsePickedAt(value) {
12
39
  return Number.isNaN(parsed.getTime()) ? null : parsed;
13
40
  }
14
41
 
42
+ /**
43
+ * Parse a tick timestamp combining date and time label.
44
+ * @param {string} dateStr - Date in YYYY-MM-DD format
45
+ * @param {string} timeLabel - Time label like "2:30 PM"
46
+ * @returns {Date|null} Parsed date or null if invalid
47
+ */
15
48
  function parseTickDate(dateStr, timeLabel) {
16
49
  const match = String(timeLabel || '').trim().toLowerCase().match(/^(\d{1,2}):(\d{2})(?:\s*(am|pm))?$/);
17
50
  if (!match) return null;
@@ -28,6 +61,13 @@ function parseTickDate(dateStr, timeLabel) {
28
61
  return Number.isNaN(parsed.getTime()) ? null : parsed;
29
62
  }
30
63
 
64
+ /**
65
+ * List journal log files within a date range.
66
+ * @param {string} atrisDir - Path to the atris directory
67
+ * @param {Date} startDate - Start of date range
68
+ * @param {Date} [endDate] - End of date range (defaults to today)
69
+ * @returns {Array} Array of log file paths
70
+ */
31
71
  function listLogFiles(atrisDir, startDate, endDate = new Date()) {
32
72
  const logsDir = path.join(atrisDir, 'logs');
33
73
  if (!fs.existsSync(logsDir)) return [];
@@ -141,16 +181,20 @@ function buildScorecardData(atrisDir, { slug, pickedAt } = {}) {
141
181
  const todo = parseTodo(todoPath);
142
182
  const startAt = parsePickedAt(pickedAt) || new Date();
143
183
  const rewardStats = collectRewardStats(atrisDir, pickedAt);
144
- const completedEndgame = todo.completed.filter(t => t.tag === 'endgame').length;
184
+ // Count shipped tasks from journal completions (tasks get deleted from TODO.md after completion)
185
+ const completedFromTodo = todo.completed.filter(t => t.tag === 'endgame').length;
145
186
  const activeEndgame = todo.backlog.filter(t => t.tag === 'endgame').length
146
187
  + todo.inProgress.filter(t => t.tag === 'endgame').length;
188
+ // Fall back to reward tick count if TODO completions were already pruned
189
+ const shipped = completedFromTodo > 0 ? completedFromTodo : rewardStats.totalTicks - rewardStats.haltedTicks;
190
+ const attempted = shipped + activeEndgame + rewardStats.haltedTicks;
147
191
 
148
192
  return {
149
193
  slug,
150
194
  startDate: startAt.toISOString().slice(0, 10),
151
195
  endDate: new Date().toISOString().slice(0, 10),
152
- tasksShipped: completedEndgame,
153
- tasksAttempted: completedEndgame + activeEndgame,
196
+ tasksShipped: Math.max(shipped, 0),
197
+ tasksAttempted: Math.max(attempted, shipped),
154
198
  wallClockHours: Math.max(0, (Date.now() - startAt.getTime()) / (1000 * 60 * 60)),
155
199
  haltRatio: rewardStats.totalTicks > 0 ? rewardStats.haltedTicks / rewardStats.totalTicks : 0,
156
200
  totalReward: rewardStats.totalReward,
@@ -191,7 +235,13 @@ function writeScorecard(atrisDir, data) {
191
235
  throw new Error('Scorecard: slug is required');
192
236
  }
193
237
 
194
- const scorecardsPath = path.join(atrisDir, 'scorecards.md');
238
+ const scorecardsPath = getScorecardsPath(atrisDir);
239
+
240
+ // Dedupe guard: don't write the same slug twice
241
+ const existing = readScorecards(atrisDir);
242
+ if (existing.some(sc => sc.slug === slug)) {
243
+ return; // already written
244
+ }
195
245
 
196
246
  // Ensure scorecards.md exists
197
247
  if (!fs.existsSync(scorecardsPath)) {
@@ -244,14 +294,14 @@ function detectEndgameCompletion(atrisDir) {
244
294
  * Parse scorecards.md and return array of scorecard objects.
245
295
  */
246
296
  function readScorecards(atrisDir) {
247
- const scorecardsPath = path.join(atrisDir, 'scorecards.md');
297
+ const scorecardsPath = getScorecardsPath(atrisDir);
248
298
  if (!fs.existsSync(scorecardsPath)) return [];
249
299
 
250
300
  const content = fs.readFileSync(scorecardsPath, 'utf8');
251
301
  const scorecards = [];
252
302
 
253
303
  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+)$/);
304
+ 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
305
  if (!match) continue;
256
306
 
257
307
  const [, endDate, slug, shipped, attempted, wallClockStr, haltPercent, reward, lessons] = match;
@@ -280,6 +330,8 @@ function readScorecards(atrisDir) {
280
330
  }
281
331
 
282
332
  module.exports = {
333
+ PRIVATE_MEMORY_ROOT,
334
+ getScorecardsPath,
283
335
  buildScorecardData,
284
336
  writeScorecard,
285
337
  readScorecards,
@@ -0,0 +1,59 @@
1
+ // Atris sync telemetry — emit one event per `atris push` / `atris pull`.
2
+ //
3
+ // Why awaited (not fire-and-forget): push.js / pull.js call process.exit(1)
4
+ // on most failure paths. A fire-and-forget POST gets killed mid-flight and
5
+ // the RL loop loses signals on the exact failures it most needs to learn.
6
+ //
7
+ // Best-effort: any error swallowed silently — telemetry never blocks UX.
8
+ // Hard 2s timeout so a flaky control plane can't slow real operations.
9
+
10
+ const { apiRequestJson } = require('../utils/api');
11
+
12
+ let _cachedVersion = null;
13
+ function cliVersion() {
14
+ if (_cachedVersion) return _cachedVersion;
15
+ try {
16
+ _cachedVersion = require('../package.json').version || 'unknown';
17
+ } catch {
18
+ _cachedVersion = 'unknown';
19
+ }
20
+ return _cachedVersion;
21
+ }
22
+
23
+ async function emitSyncEvent(token, businessId, workspaceId, op, outcome, latencyMs, extras = {}) {
24
+ if (!token || !businessId || !workspaceId || !op || !outcome) return false;
25
+ const event = {
26
+ business_id: businessId,
27
+ workspace_id: workspaceId,
28
+ op,
29
+ outcome,
30
+ latency_ms: Math.max(0, Math.round(latencyMs || 0)),
31
+ bytes_transferred: extras.bytes_transferred || 0,
32
+ bytes_changed: extras.bytes_changed || 0,
33
+ files_pushed: extras.files_pushed || 0,
34
+ files_deleted: extras.files_deleted || 0,
35
+ files_unchanged: extras.files_unchanged || 0,
36
+ cli_version: cliVersion(),
37
+ };
38
+ if (extras.error_detail) event.error_detail = String(extras.error_detail).slice(0, 500);
39
+
40
+ try {
41
+ await apiRequestJson('/atris-sync/telemetry', {
42
+ method: 'POST',
43
+ token,
44
+ body: event,
45
+ timeoutMs: 2000,
46
+ retries: 0,
47
+ });
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ function startTimer() {
55
+ const t0 = Date.now();
56
+ return () => Date.now() - t0;
57
+ }
58
+
59
+ module.exports = { emitSyncEvent, startTimer };
package/lib/todo.js CHANGED
@@ -17,6 +17,12 @@ function parseTodo(todoPath) {
17
17
  };
18
18
  }
19
19
 
20
+ /**
21
+ * Parse a specific section from TODO.md content into task objects.
22
+ * @param {string} content - Full TODO.md content
23
+ * @param {string} sectionName - Section name to extract (e.g., 'Backlog')
24
+ * @returns {Array} Array of parsed task objects
25
+ */
20
26
  function parseSection(content, sectionName) {
21
27
  const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
28
  const match = content.match(new RegExp(`##\\s+${escaped}\\n([\\s\\S]*?)(?=\\n##|$)`, 'i'));