atris 3.15.13 → 3.15.22

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 (93) hide show
  1. package/AGENTS.md +84 -8
  2. package/README.md +5 -1
  3. package/atris/AGENTS.md +46 -1
  4. package/atris/CLAUDE.md +36 -1
  5. package/atris/GEMINI.md +14 -1
  6. package/atris/atris.md +12 -1
  7. package/atris/atrisDev.md +3 -2
  8. package/atris/context/README.md +11 -0
  9. package/atris/features/company-brain-sync/validate.md +5 -5
  10. package/atris/learnings.jsonl +1 -0
  11. package/atris/policies/atris-design.md +2 -0
  12. package/atris/skills/aeo/SKILL.md +2 -2
  13. package/atris/skills/atris/SKILL.md +15 -62
  14. package/atris/skills/design/SKILL.md +2 -0
  15. package/atris/skills/imessage/SKILL.md +19 -2
  16. package/atris/skills/loop/SKILL.md +6 -5
  17. package/atris/skills/magic-inbox/SKILL.md +1 -1
  18. package/atris/team/_template/MEMBER.md +23 -1
  19. package/atris/team/brainstormer/START_HERE.md +6 -0
  20. package/atris/team/executor/MEMBER.md +13 -0
  21. package/atris/team/executor/START_HERE.md +6 -0
  22. package/atris/team/launcher/START_HERE.md +6 -0
  23. package/atris/team/mission-lead/MEMBER.md +39 -0
  24. package/atris/team/mission-lead/MISSION.md +33 -0
  25. package/atris/team/mission-lead/START_HERE.md +6 -0
  26. package/atris/team/navigator/MEMBER.md +11 -0
  27. package/atris/team/navigator/START_HERE.md +6 -0
  28. package/atris/team/opus-overnight/MEMBER.md +39 -0
  29. package/atris/team/opus-overnight/MISSION.md +61 -0
  30. package/atris/team/opus-overnight/START_HERE.md +6 -0
  31. package/atris/team/opus-overnight/STEERING.md +35 -0
  32. package/atris/team/researcher/START_HERE.md +6 -0
  33. package/atris/team/validator/MEMBER.md +26 -6
  34. package/atris/team/validator/START_HERE.md +6 -0
  35. package/atris/wiki/concepts/agent-activation-contract.md +79 -0
  36. package/atris/wiki/concepts/workspace-initialization-contract.md +73 -0
  37. package/atris/wiki/index.md +27 -0
  38. package/atris/wiki/sources/atris-labs-2026-05-10.txt +17 -0
  39. package/atris/wiki/sources/atris-labs-goals-2026-05-10.txt +15 -0
  40. package/atris/wiki/sources/atrisos-generative-ui-product-surface-2026-05-10.txt +10 -0
  41. package/atris/wiki/sources/jack-dorsey-2026-05-10.txt +12 -0
  42. package/atris.md +49 -13
  43. package/bin/atris.js +660 -22
  44. package/commands/activate.js +12 -3
  45. package/commands/aeo.js +1 -1
  46. package/commands/align.js +10 -10
  47. package/commands/analytics.js +9 -4
  48. package/commands/app.js +2 -0
  49. package/commands/apps.js +276 -0
  50. package/commands/auth.js +1 -1
  51. package/commands/autopilot.js +74 -5
  52. package/commands/brain.js +536 -61
  53. package/commands/brainstorm.js +12 -12
  54. package/commands/business-sync.js +142 -24
  55. package/commands/clean.js +9 -6
  56. package/commands/codex-goal.js +311 -0
  57. package/commands/errors.js +11 -1
  58. package/commands/feedback.js +55 -17
  59. package/commands/fork.js +2 -2
  60. package/commands/gm.js +376 -0
  61. package/commands/init.js +80 -3
  62. package/commands/integrations.js +524 -0
  63. package/commands/learn.js +25 -16
  64. package/commands/lesson.js +41 -0
  65. package/commands/lifecycle.js +2 -2
  66. package/commands/member.js +2416 -9
  67. package/commands/mission.js +1776 -0
  68. package/commands/now.js +48 -7
  69. package/commands/play.js +425 -0
  70. package/commands/publish.js +2 -1
  71. package/commands/pull.js +72 -29
  72. package/commands/push.js +199 -17
  73. package/commands/review.js +51 -13
  74. package/commands/skill.js +2 -2
  75. package/commands/soul.js +19 -13
  76. package/commands/status.js +6 -1
  77. package/commands/sync.js +5 -4
  78. package/commands/task.js +1041 -147
  79. package/commands/terminal.js +5 -5
  80. package/commands/verify.js +7 -5
  81. package/commands/visualize.js +7 -0
  82. package/commands/wiki.js +53 -16
  83. package/commands/workflow.js +298 -54
  84. package/commands/workspace-clean.js +1 -1
  85. package/commands/worktree.js +468 -0
  86. package/commands/xp.js +1608 -0
  87. package/lib/manifest.js +34 -4
  88. package/lib/scorecard.js +3 -2
  89. package/lib/task-db.js +408 -27
  90. package/lib/todo-fallback.js +28 -2
  91. package/lib/todo.js +5 -3
  92. package/package.json +23 -2
  93. package/utils/update-check.js +51 -1
package/lib/task-db.js CHANGED
@@ -30,6 +30,13 @@ const { DatabaseSync } = require('node:sqlite');
30
30
 
31
31
  const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
32
32
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
33
+ const TODO_RENDER_DONE_LIMIT = 8;
34
+ const PROJECTION_DONE_LIMIT = 8;
35
+ const PROJECTION_EVENT_LIMIT = 8;
36
+ const PROJECTION_MESSAGE_LIMIT = 6;
37
+ const PROJECTION_PAYLOAD_TEXT_LIMIT = 1000;
38
+ const AGENT_CERTIFICATION_REVIEW_PASSES = 2;
39
+ const TASK_REF_GENERIC_TOKENS = new Set(['app', 'atris', 'atrisos', 'project', 'repo', 'workspace']);
33
40
 
34
41
  const SCHEMA = `
35
42
  CREATE TABLE IF NOT EXISTS tasks (
@@ -160,12 +167,91 @@ function sourceKey(sourceFile, title) {
160
167
  return h.digest('hex');
161
168
  }
162
169
 
170
+ function normalizeTaskRef(value) {
171
+ return String(value || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
172
+ }
173
+
174
+ function workspaceRefPrefix(ws) {
175
+ const base = path.basename(String(ws || 'task')).toLowerCase();
176
+ const parts = base.split(/[^a-z0-9]+/).filter(Boolean);
177
+ const useful = parts.filter(p => !TASK_REF_GENERIC_TOKENS.has(p));
178
+ if (useful.length > 1) {
179
+ const leading = useful.slice(0, -1).map(p => p[0]).join('').toUpperCase();
180
+ const last = useful[useful.length - 1].toUpperCase().replace(/[^A-Z0-9]/g, '');
181
+ const lastKey = last[0] + last.slice(1).replace(/[AEIOU]/g, '');
182
+ const combined = `${leading}${lastKey}`.replace(/[^A-Z0-9]/g, '');
183
+ if (combined) return combined.slice(0, 3).padEnd(3, 'X');
184
+ }
185
+ const picked = useful.pop()
186
+ || parts[parts.length - 1]
187
+ || 'task';
188
+ const token = picked.toUpperCase().replace(/[^A-Z0-9]/g, '');
189
+ if (!token) return 'TSK';
190
+ if (token.length <= 3) return token.padEnd(3, 'X');
191
+ const consonantKey = token[0] + token.slice(1).replace(/[AEIOU]/g, '');
192
+ return (consonantKey.length >= 3 ? consonantKey : token).slice(0, 3);
193
+ }
194
+
195
+ function taskDisplayRef(row, index) {
196
+ return `${workspaceRefPrefix(row && row.workspace_root)}-${Number(index) + 1}`;
197
+ }
198
+
199
+ function shortestUniqueTaskRef(id, ids, minLength = 8) {
200
+ const normalized = normalizeTaskRef(id);
201
+ if (!normalized) return '';
202
+ const all = (Array.isArray(ids) ? ids : []).map(normalizeTaskRef).filter(Boolean);
203
+ for (let length = Math.min(minLength, normalized.length); length <= normalized.length; length += 1) {
204
+ const prefix = normalized.slice(0, length);
205
+ const matches = all.filter(candidate => candidate.startsWith(prefix));
206
+ if (matches.length <= 1) return prefix;
207
+ }
208
+ return normalized;
209
+ }
210
+
211
+ function withTaskDisplayRefs(rows, refRows = rows) {
212
+ const list = Array.isArray(rows) ? rows : [];
213
+ const referenceInput = Array.isArray(refRows) ? refRows : list;
214
+ const referenceIds = new Set(referenceInput.map(row => row && row.id).filter(Boolean));
215
+ const referenceList = [
216
+ ...referenceInput,
217
+ ...list.filter(row => row && row.id && !referenceIds.has(row.id) && !(row.metadata && row.metadata.markdown_source)),
218
+ ];
219
+ const byWorkspace = new Map();
220
+ for (const row of referenceList) {
221
+ const key = row && row.workspace_root || '';
222
+ if (!byWorkspace.has(key)) byWorkspace.set(key, []);
223
+ byWorkspace.get(key).push(row);
224
+ }
225
+ const refs = new Map();
226
+ for (const group of byWorkspace.values()) {
227
+ const sorted = [...group]
228
+ .sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id || '').localeCompare(String(b.id || '')))
229
+ const ids = sorted.map(row => row && row.id);
230
+ sorted.forEach((row, index) => {
231
+ refs.set(row.id, {
232
+ display_id: taskDisplayRef(row, index),
233
+ legacy_ref: shortestUniqueTaskRef(row.id, ids, 8),
234
+ });
235
+ });
236
+ }
237
+ return list.map(row => ({ ...row, ...(refs.get(row.id) || {}) }));
238
+ }
239
+
240
+ function taskDisplayRefMap(rows) {
241
+ const map = new Map();
242
+ for (const row of withTaskDisplayRefs(rows)) {
243
+ map.set(row.id, row.display_id);
244
+ }
245
+ return map;
246
+ }
247
+
163
248
  function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, status, claimedBy }) {
164
249
  if (!title || !String(title).trim()) throw new Error('title required');
165
250
  const now = Date.now();
166
251
  const id = newId();
167
- const taskStatus = ['open', 'claimed', 'done', 'failed'].includes(status) ? status : 'open';
168
- const claimedAt = taskStatus === 'claimed' ? now : null;
252
+ const taskStatus = ['open', 'claimed', 'review', 'done', 'failed'].includes(status) ? status : 'open';
253
+ const initialClaimedBy = (taskStatus === 'claimed' || taskStatus === 'review') ? (claimedBy || null) : null;
254
+ const claimedAt = initialClaimedBy ? now : null;
169
255
  // Idempotent on (workspace_root, source_key) when source_key supplied.
170
256
  if (sk) {
171
257
  const existing = db.prepare(
@@ -184,7 +270,7 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
184
270
  tag || null,
185
271
  ws,
186
272
  sk || null,
187
- taskStatus === 'claimed' ? (claimedBy || null) : null,
273
+ initialClaimedBy,
188
274
  claimedAt,
189
275
  now,
190
276
  now,
@@ -217,7 +303,7 @@ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
217
303
  FROM tasks
218
304
  ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
219
305
  ORDER BY
220
- CASE status WHEN 'open' THEN 0 WHEN 'claimed' THEN 1 WHEN 'failed' THEN 2 WHEN 'done' THEN 3 ELSE 4 END,
306
+ CASE status WHEN 'open' THEN 0 WHEN 'claimed' THEN 1 WHEN 'review' THEN 2 WHEN 'failed' THEN 3 WHEN 'done' THEN 4 ELSE 5 END,
221
307
  created_at DESC
222
308
  ${limit ? 'LIMIT ' + Number(limit) : ''}
223
309
  `;
@@ -272,23 +358,24 @@ function claimTask(db, { id, claimedBy }) {
272
358
  return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
273
359
  }
274
360
 
275
- function doneTask(db, { id, status }) {
361
+ function doneTask(db, { id, status, actor, allowReview = false }) {
276
362
  if (!id) throw new Error('id required');
277
363
  const final = status || 'done';
278
364
  if (!['done', 'failed'].includes(final)) throw new Error('status must be done|failed');
279
365
  const now = Date.now();
366
+ const allowedStatuses = allowReview ? "'open', 'claimed', 'review'" : "'open', 'claimed'";
280
367
  const result = withBusyRetry(() => db.prepare(`
281
368
  UPDATE tasks
282
369
  SET status = ?, done_at = ?, updated_at = ?
283
370
  WHERE id = ?
284
- AND status IN ('open', 'claimed')
371
+ AND status IN (${allowedStatuses})
285
372
  `).run(final, now, now, id));
286
373
  if (result.changes === 1) {
287
374
  const row = db.prepare('SELECT id, workspace_root FROM tasks WHERE id = ?').get(id);
288
375
  appendTaskEvent(db, {
289
376
  taskId: id,
290
377
  workspaceRoot: row.workspace_root,
291
- actor: process.env.ATRIS_AGENT_ID || process.env.USER || null,
378
+ actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
292
379
  eventType: final === 'done' ? 'completed' : 'blocked',
293
380
  payload: { status: final },
294
381
  });
@@ -296,6 +383,117 @@ function doneTask(db, { id, status }) {
296
383
  return { updated: result.changes === 1 };
297
384
  }
298
385
 
386
+ function readyTask(db, { id, actor, proof, lesson, nextTask }) {
387
+ if (!id) throw new Error('id required');
388
+ const text = String(proof || '').trim();
389
+ if (!text) throw new Error('proof required');
390
+ const row = getTask(db, id);
391
+ if (!row) return { ready: false, reason: 'not_found' };
392
+ if (!['open', 'claimed', 'review'].includes(row.status)) {
393
+ return { ready: false, reason: `already_${row.status}` };
394
+ }
395
+ const now = Date.now();
396
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
397
+ const reviewPassCount = Number(metadata.agent_review_pass_count || 0) + 1;
398
+ metadata.approval_status = 'pending';
399
+ metadata.agent_review_pass_count = reviewPassCount;
400
+ metadata.agent_reviewed_at = new Date(now).toISOString();
401
+ metadata.agent_reviewed_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
402
+ metadata.latest_agent_proof = text;
403
+ metadata.latest_agent_lesson = String(lesson || '').trim() || null;
404
+ metadata.latest_agent_next_task = String(nextTask || '').trim() || null;
405
+ if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES) {
406
+ metadata.agent_certified = true;
407
+ metadata.agent_certified_at = new Date(now).toISOString();
408
+ metadata.agent_certified_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
409
+ metadata.agent_certification_policy = `${AGENT_CERTIFICATION_REVIEW_PASSES}_agent_review_passes`;
410
+ }
411
+ const result = withBusyRetry(() => db.prepare(`
412
+ UPDATE tasks
413
+ SET status = 'review',
414
+ done_at = NULL,
415
+ updated_at = ?,
416
+ metadata = ?
417
+ WHERE id = ?
418
+ AND status IN ('open', 'claimed', 'review')
419
+ `).run(now, JSON.stringify(metadata), id));
420
+ if (result.changes !== 1) return { ready: false, reason: 'not_open_claimed_or_review' };
421
+ const updated = getTask(db, id);
422
+ const event = appendTaskEvent(db, {
423
+ taskId: id,
424
+ workspaceRoot: updated.workspace_root,
425
+ actor: actor || null,
426
+ eventType: 'proof_ready',
427
+ payload: {
428
+ proof: text,
429
+ lesson: metadata.latest_agent_lesson,
430
+ next_task: metadata.latest_agent_next_task,
431
+ approval_status: 'pending',
432
+ review_pass_count: reviewPassCount,
433
+ agent_certified: metadata.agent_certified === true,
434
+ agent_certification_policy: metadata.agent_certification_policy || null,
435
+ },
436
+ });
437
+ return { ready: true, event, row: updated };
438
+ }
439
+
440
+ function reviseTask(db, { id, actor, note }) {
441
+ if (!id) throw new Error('id required');
442
+ const text = String(note || '').trim();
443
+ if (!text) throw new Error('note required');
444
+ const row = getTask(db, id);
445
+ if (!row) return { revised: false, reason: 'not_found' };
446
+ if (row.status !== 'review') {
447
+ return { revised: false, reason: `not_reviewable_${row.status}` };
448
+ }
449
+ const now = Date.now();
450
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
451
+ const revisionCount = Number(metadata.human_revision_count || 0) + 1;
452
+ for (const key of [
453
+ 'agent_review_pass_count',
454
+ 'agent_reviewed_at',
455
+ 'agent_reviewed_by',
456
+ 'latest_agent_proof',
457
+ 'latest_agent_lesson',
458
+ 'latest_agent_next_task',
459
+ 'agent_certified',
460
+ 'agent_certified_at',
461
+ 'agent_certified_by',
462
+ 'agent_certification_policy',
463
+ ]) {
464
+ delete metadata[key];
465
+ }
466
+ metadata.approval_status = 'revise';
467
+ metadata.human_revision_count = revisionCount;
468
+ metadata.human_revision_at = new Date(now).toISOString();
469
+ metadata.human_revision_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
470
+ metadata.human_revision_note = text;
471
+ const revisedStatus = row.claimed_by ? 'claimed' : 'open';
472
+ const result = withBusyRetry(() => db.prepare(`
473
+ UPDATE tasks
474
+ SET status = ?,
475
+ done_at = NULL,
476
+ updated_at = ?,
477
+ metadata = ?
478
+ WHERE id = ?
479
+ AND status = 'review'
480
+ `).run(revisedStatus, now, JSON.stringify(metadata), id));
481
+ if (result.changes !== 1) return { revised: false, reason: 'not_updated' };
482
+ const updated = getTask(db, id);
483
+ const event = appendTaskEvent(db, {
484
+ taskId: id,
485
+ workspaceRoot: updated.workspace_root,
486
+ actor: actor || null,
487
+ eventType: 'revision_requested',
488
+ payload: {
489
+ note: text,
490
+ approval_status: 'revise',
491
+ revision_count: revisionCount,
492
+ },
493
+ });
494
+ return { revised: true, event, row: updated };
495
+ }
496
+
299
497
  function appendTaskEvent(db, { taskId, workspaceRoot: ws, actor, eventType, payload }) {
300
498
  if (!taskId) throw new Error('taskId required');
301
499
  if (!ws) throw new Error('workspaceRoot required');
@@ -329,16 +527,17 @@ function appendTaskEvent(db, { taskId, workspaceRoot: ws, actor, eventType, payl
329
527
  };
330
528
  }
331
529
 
332
- function listTaskEvents(db, { taskId, workspaceRoot: ws, limit }) {
530
+ function listTaskEvents(db, { taskId, workspaceRoot: ws, limit, order = 'asc' }) {
333
531
  const where = [];
334
532
  const args = [];
335
533
  if (taskId) { where.push('task_id = ?'); args.push(taskId); }
336
534
  if (ws) { where.push('workspace_root = ?'); args.push(ws); }
535
+ const sort = String(order || '').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
337
536
  const sql = `
338
537
  SELECT event_id, task_id, version, workspace_root, actor, event_type, payload, created_at
339
538
  FROM task_events
340
539
  ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
341
- ORDER BY created_at ASC, version ASC
540
+ ORDER BY created_at ${sort}, version ${sort}
342
541
  ${limit ? 'LIMIT ' + Number(limit) : ''}
343
542
  `;
344
543
  return db.prepare(sql).all(...args).map(r => ({
@@ -363,17 +562,34 @@ function noteTask(db, { id, actor, content }) {
363
562
  return { noted: true, event };
364
563
  }
365
564
 
366
- function reviewTask(db, { id, actor, reward, lesson, nextTask, proof }) {
565
+ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, careerXpEligible = false, clearedFields = [] }) {
367
566
  if (!id) throw new Error('id required');
368
567
  const row = getTask(db, id);
369
568
  if (!row) return { reviewed: false, reason: 'not_found' };
370
569
  const numericReward = Number.isFinite(Number(reward)) ? Number(reward) : 0;
570
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
571
+ if (numericReward > 0 && row.status === 'done') {
572
+ metadata.approval_status = 'accepted';
573
+ metadata.accepted_at = new Date().toISOString();
574
+ metadata.accepted_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
575
+ withBusyRetry(() => db.prepare(`
576
+ UPDATE tasks
577
+ SET metadata = ?,
578
+ updated_at = ?
579
+ WHERE id = ?
580
+ `).run(JSON.stringify(metadata), Date.now(), id));
581
+ }
371
582
  const payload = {
372
583
  reward: numericReward,
373
584
  lesson: String(lesson || '').trim(),
374
585
  next_task: String(nextTask || '').trim() || null,
375
586
  proof: String(proof || '').trim() || null,
587
+ career_xp_eligible: Boolean(careerXpEligible),
376
588
  };
589
+ const clearedReviewFields = Array.isArray(clearedFields)
590
+ ? Array.from(new Set(clearedFields.filter(field => field === 'lesson' || field === 'next_task')))
591
+ : [];
592
+ if (clearedReviewFields.length) payload.cleared_review_fields = clearedReviewFields;
377
593
  const event = appendTaskEvent(db, {
378
594
  taskId: id,
379
595
  workspaceRoot: row.workspace_root,
@@ -381,12 +597,44 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof }) {
381
597
  eventType: 'reviewed',
382
598
  payload,
383
599
  });
384
- const episode = taskEpisodeFromReview(row, event, payload);
600
+ const episode = taskEpisodeFromReview({ ...row, metadata }, event, payload);
385
601
  appendTaskEpisode(row.workspace_root, episode);
386
602
  return { reviewed: true, event, episode };
387
603
  }
388
604
 
605
+ function compactEpisodeText(value, max = 240) {
606
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
607
+ if (!text) return null;
608
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 3)).trim()}...` : text;
609
+ }
610
+
611
+ function goalSignalFromTaskMetadata(metadata) {
612
+ const goalId = compactEpisodeText(metadata.goal_id || metadata.goalId || metadata.goal?.id || '', 120);
613
+ const objective = compactEpisodeText(
614
+ metadata.goal_objective || metadata.goalObjective || metadata.goal?.objective || metadata.goal || '',
615
+ 240,
616
+ );
617
+ if (!goalId && !objective) return null;
618
+ return {
619
+ goal_id: goalId,
620
+ objective,
621
+ };
622
+ }
623
+
624
+ function reviewOutcomeLabel(reward) {
625
+ const value = Number(reward);
626
+ if (!Number.isFinite(value)) return 'reviewed';
627
+ if (value > 0) return 'accepted';
628
+ if (value < 0) return 'rejected';
629
+ return 'revised';
630
+ }
631
+
389
632
  function taskEpisodeFromReview(row, event, payload) {
633
+ const metadata = row.metadata || {};
634
+ const rewardValue = Number(payload.reward);
635
+ const hasProof = Boolean(String(payload.proof || '').trim());
636
+ const label = reviewOutcomeLabel(payload.reward);
637
+ const doneForXp = row.status === 'done';
390
638
  return {
391
639
  schema: 'atris.task_episode.v1',
392
640
  episode_id: event.event_id,
@@ -398,7 +646,7 @@ function taskEpisodeFromReview(row, event, payload) {
398
646
  status: row.status,
399
647
  tag: row.tag,
400
648
  claimed_by: row.claimed_by,
401
- metadata: row.metadata || {},
649
+ metadata,
402
650
  },
403
651
  action: {
404
652
  event_type: 'reviewed',
@@ -412,6 +660,21 @@ function taskEpisodeFromReview(row, event, payload) {
412
660
  lesson: payload.lesson,
413
661
  proof: payload.proof,
414
662
  next_task_suggestion: payload.next_task,
663
+ goal: goalSignalFromTaskMetadata(metadata),
664
+ career_xp: {
665
+ eligible: payload.career_xp_eligible === true && label === 'accepted' && hasProof && doneForXp,
666
+ source: 'task_review',
667
+ reward: Number.isFinite(rewardValue) ? rewardValue : 0,
668
+ proof_required: true,
669
+ },
670
+ rl: {
671
+ label,
672
+ source: 'task_review',
673
+ reward: Number.isFinite(rewardValue) ? rewardValue : 0,
674
+ has_proof: hasProof,
675
+ has_lesson: Boolean(String(payload.lesson || '').trim()),
676
+ has_next_task: Boolean(String(payload.next_task || '').trim()),
677
+ },
415
678
  };
416
679
  }
417
680
 
@@ -422,10 +685,81 @@ function appendTaskEpisode(workspaceRoot, episode) {
422
685
  return filePath;
423
686
  }
424
687
 
425
- function taskProjection(db, { workspaceRoot: ws, taskId, limit = 500 } = {}) {
688
+ function clipProjectionText(value, max = PROJECTION_PAYLOAD_TEXT_LIMIT) {
689
+ const text = String(value || '');
690
+ if (text.length <= max) return text;
691
+ return `${text.slice(0, Math.max(0, max - 1))}…`;
692
+ }
693
+
694
+ function compactProjectionPayload(value) {
695
+ if (typeof value === 'string') return clipProjectionText(value);
696
+ if (!value || typeof value !== 'object') return value;
697
+ if (Array.isArray(value)) return value.slice(0, 20).map(compactProjectionPayload);
698
+ const out = {};
699
+ for (const [key, item] of Object.entries(value)) {
700
+ if (typeof item === 'string') out[key] = clipProjectionText(item);
701
+ else if (item && typeof item === 'object') out[key] = compactProjectionPayload(item);
702
+ else out[key] = item;
703
+ }
704
+ return out;
705
+ }
706
+
707
+ function compactProjectionEvent(event) {
708
+ return {
709
+ ...event,
710
+ payload: compactProjectionPayload(event.payload),
711
+ };
712
+ }
713
+
714
+ function selectProjectionRows(rows, { taskId, includeHistory, doneLimit }) {
715
+ if (taskId || includeHistory) {
716
+ return {
717
+ visibleRows: rows,
718
+ hiddenDoneCount: 0,
719
+ };
720
+ }
721
+ const visibleRows = [];
722
+ let shownDone = 0;
723
+ let hiddenDoneCount = 0;
724
+ for (const row of rows) {
725
+ if (row.status === 'done') {
726
+ if (shownDone < doneLimit) {
727
+ visibleRows.push(row);
728
+ shownDone += 1;
729
+ } else {
730
+ hiddenDoneCount += 1;
731
+ }
732
+ continue;
733
+ }
734
+ visibleRows.push(row);
735
+ }
736
+ return { visibleRows, hiddenDoneCount };
737
+ }
738
+
739
+ function taskProjection(db, {
740
+ workspaceRoot: ws,
741
+ taskId,
742
+ limit = 500,
743
+ includeHistory = Boolean(taskId),
744
+ doneLimit = PROJECTION_DONE_LIMIT,
745
+ eventLimit = PROJECTION_EVENT_LIMIT,
746
+ messageLimit = PROJECTION_MESSAGE_LIMIT,
747
+ } = {}) {
426
748
  const rows = taskId
427
749
  ? [getTask(db, taskId)].filter(Boolean)
428
750
  : listTasks(db, { workspaceRoot: ws || null, limit });
751
+ const refRows = taskId && rows[0]
752
+ ? listTasks(db, { workspaceRoot: rows[0].workspace_root })
753
+ : listTasks(db, { workspaceRoot: ws || null });
754
+ const refById = new Map(withTaskDisplayRefs(refRows).map(row => [row.id, {
755
+ display_id: row.display_id,
756
+ legacy_ref: row.legacy_ref,
757
+ }]));
758
+ const { visibleRows, hiddenDoneCount } = selectProjectionRows(rows, {
759
+ taskId,
760
+ includeHistory,
761
+ doneLimit: Math.max(0, Number(doneLimit) || 0),
762
+ });
429
763
  const events = listTaskEvents(db, {
430
764
  taskId: taskId || null,
431
765
  workspaceRoot: taskId ? null : (ws || null),
@@ -440,19 +774,32 @@ function taskProjection(db, { workspaceRoot: ws, taskId, limit = 500 } = {}) {
440
774
  schema: 'atris.task_projection.v1',
441
775
  generated_at: new Date().toISOString(),
442
776
  workspace_root: ws || (rows[0] && rows[0].workspace_root) || null,
443
- tasks: rows.map(row => {
777
+ surface: {
778
+ compact: !includeHistory,
779
+ full_task_count: rows.length,
780
+ visible_task_count: visibleRows.length,
781
+ hidden_done_count: hiddenDoneCount,
782
+ done_limit: includeHistory ? null : Math.max(0, Number(doneLimit) || 0),
783
+ event_limit: includeHistory ? null : Math.max(0, Number(eventLimit) || 0),
784
+ message_limit: includeHistory ? null : Math.max(0, Number(messageLimit) || 0),
785
+ full_ledger_command: taskId ? `atris task events ${taskId}` : 'atris task events --all',
786
+ },
787
+ tasks: visibleRows.map(row => {
444
788
  const taskEvents = byTask.get(row.id) || [];
445
789
  const latest = taskEvents.length ? taskEvents[taskEvents.length - 1] : null;
446
- const messages = taskEvents
790
+ const allMessages = taskEvents
447
791
  .filter(e => e.event_type === 'message')
448
792
  .map(e => ({
449
793
  version: e.version,
450
794
  actor: e.actor,
451
- content: e.payload && e.payload.content || '',
795
+ content: clipProjectionText(e.payload && e.payload.content || ''),
452
796
  created_at: e.created_at,
453
797
  }));
798
+ const visibleMessages = includeHistory ? allMessages : allMessages.slice(-Math.max(0, Number(messageLimit) || 0));
799
+ const visibleEvents = includeHistory ? taskEvents : taskEvents.slice(-Math.max(0, Number(eventLimit) || 0)).map(compactProjectionEvent);
454
800
  return {
455
801
  id: row.id,
802
+ ...(refById.get(row.id) || {}),
456
803
  title: row.title,
457
804
  status: row.status,
458
805
  tag: row.tag,
@@ -464,25 +811,47 @@ function taskProjection(db, { workspaceRoot: ws, taskId, limit = 500 } = {}) {
464
811
  metadata: row.metadata || {},
465
812
  current_version: latest ? latest.version : 0,
466
813
  latest_event_type: latest ? latest.event_type : null,
467
- messages,
468
- events: taskEvents,
814
+ messages: visibleMessages,
815
+ events: visibleEvents,
816
+ history: {
817
+ event_count: taskEvents.length,
818
+ message_count: allMessages.length,
819
+ events_visible: visibleEvents.length,
820
+ messages_visible: visibleMessages.length,
821
+ events_truncated: !includeHistory && taskEvents.length > visibleEvents.length,
822
+ messages_truncated: !includeHistory && allMessages.length > visibleMessages.length,
823
+ },
469
824
  };
470
825
  }),
471
826
  };
472
827
  }
473
828
 
474
- function renderTodoMarkdown(rows, { title = 'TODO.md' } = {}) {
829
+ function renderTodoMarkdown(rows, { title = 'TODO.md', doneLimit = TODO_RENDER_DONE_LIMIT, refRows = rows, preservedSections = [] } = {}) {
830
+ const displayRows = withTaskDisplayRefs(rows, refRows);
475
831
  const buckets = {
476
- open: rows.filter(r => r.status === 'open'),
477
- claimed: rows.filter(r => r.status === 'claimed'),
478
- failed: rows.filter(r => r.status === 'failed'),
479
- done: rows.filter(r => r.status === 'done'),
832
+ open: displayRows.filter(r => r.status === 'open'),
833
+ claimed: displayRows.filter(r => r.status === 'claimed'),
834
+ review: displayRows.filter(r => r.status === 'review'),
835
+ failed: displayRows.filter(r => r.status === 'failed'),
836
+ done: displayRows.filter(r => r.status === 'done'),
480
837
  };
481
838
  const lines = [`# ${title}`, '', '> Regenerated from durable Atris task state. Do not treat this file as truth.', ''];
839
+ for (const section of preservedSections) {
840
+ const text = String(section || '').trim();
841
+ if (!text) continue;
842
+ lines.push(text, '');
843
+ }
482
844
  appendSection(lines, 'Backlog', buckets.open);
483
845
  appendSection(lines, 'In Progress', buckets.claimed);
846
+ appendSection(lines, 'Review', buckets.review);
484
847
  appendSection(lines, 'Blocked', buckets.failed);
485
- appendSection(lines, 'Completed', buckets.done);
848
+ const renderedDone = buckets.done.slice(0, Math.max(0, Number(doneLimit) || 0));
849
+ appendSection(lines, 'Completed', renderedDone);
850
+ const archivedDone = Math.max(0, buckets.done.length - renderedDone.length);
851
+ if (archivedDone > 0) {
852
+ lines.push(`(${archivedDone} older completed task${archivedDone === 1 ? '' : 's'} archived in \`atris task list --status done\` and \`atris task events\`.)`, '');
853
+ }
854
+ while (lines[lines.length - 1] === '') lines.pop();
486
855
  return lines.join('\n') + '\n';
487
856
  }
488
857
 
@@ -493,10 +862,16 @@ function appendSection(lines, name, rows) {
493
862
  return;
494
863
  }
495
864
  for (const row of rows) {
496
- const tag = row.tag ? ` [${row.tag}]` : '';
497
- lines.push(`- **[${row.id}]** ${row.title}${tag}`);
498
- if (row.claimed_by && row.status === 'claimed') lines.push(` **Claimed by:** ${row.claimed_by}`);
499
865
  const meta = row.metadata || {};
866
+ const tags = Array.isArray(meta.todo_tags) && meta.todo_tags.length
867
+ ? meta.todo_tags
868
+ : (row.tag ? [row.tag] : []);
869
+ const tag = [...new Set(tags.filter(Boolean).map(value => String(value).trim()).filter(Boolean))]
870
+ .map(value => ` [${value}]`)
871
+ .join('');
872
+ const displayRef = meta.todo_id || row.display_id || row.id;
873
+ lines.push(`- **[${displayRef}]** ${row.title}${tag}`);
874
+ if (row.claimed_by && row.status === 'claimed') lines.push(` **Claimed by:** ${row.claimed_by}`);
500
875
  if (meta.verify) lines.push(` **Verify:** ${meta.verify}`);
501
876
  }
502
877
  lines.push('');
@@ -543,12 +918,18 @@ module.exports = {
543
918
  listTasks,
544
919
  claimTask,
545
920
  doneTask,
921
+ readyTask,
922
+ reviseTask,
546
923
  noteTask,
547
924
  reviewTask,
548
925
  appendTaskEvent,
549
926
  listTaskEvents,
550
927
  taskProjection,
551
928
  renderTodoMarkdown,
929
+ normalizeTaskRef,
930
+ shortestUniqueTaskRef,
931
+ taskDisplayRefMap,
932
+ withTaskDisplayRefs,
552
933
  newId,
553
934
  // Test surface
554
935
  _SCHEMA: SCHEMA,
@@ -10,11 +10,12 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
 
12
12
  function parseTodoFile(todoPath) {
13
- if (!fs.existsSync(todoPath)) return { backlog: [], inProgress: [], completed: [] };
13
+ if (!fs.existsSync(todoPath)) return { backlog: [], inProgress: [], review: [], completed: [] };
14
14
  const content = fs.readFileSync(todoPath, 'utf8');
15
15
  return {
16
16
  backlog: parseSection(content, 'Backlog'),
17
17
  inProgress: parseSection(content, 'In Progress'),
18
+ review: parseSection(content, 'Review'),
18
19
  completed: parseSection(content, 'Completed'),
19
20
  };
20
21
  }
@@ -37,7 +38,7 @@ function cleanTaskTitle(text) {
37
38
 
38
39
  function parseSection(content, sectionName) {
39
40
  const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
- const match = content.match(new RegExp(`(?:^|\\n)##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##(?!#)\\s+|$)`, 'i'));
41
+ const match = content.match(new RegExp(`(?:^|\\n)##\\s+${escaped}[^\\n]*\\n([\\s\\S]*?)(?=\\n##(?!#)\\s+|$)`, 'i'));
41
42
  if (!match) return [];
42
43
 
43
44
  const body = (match[1] || '').trim();
@@ -50,6 +51,15 @@ function parseSection(content, sectionName) {
50
51
  for (const rawLine of lines) {
51
52
  const line = rawLine.trimEnd();
52
53
 
54
+ // Strikethrough = done. `- ~~**id:** ...~~ DONE ...` lines are kept in
55
+ // Backlog for rollback context but must not be picked as live work.
56
+ // Otherwise the autopilot picker re-selects them every tick and halts on
57
+ // "no Verify: field" (lessons.md: no-verify-field, 8 occurrences 2026-05-08..10).
58
+ if (/^- ~~/.test(line)) {
59
+ if (current) { tasks.push(current); current = null; }
60
+ continue;
61
+ }
62
+
53
63
  const taskMatch = line.match(/^- \*\*([^*:\n]+):\*\*\s*(.+)$/);
54
64
  if (taskMatch) {
55
65
  if (current) tasks.push(current);
@@ -66,6 +76,22 @@ function parseSection(content, sectionName) {
66
76
  continue;
67
77
  }
68
78
 
79
+ const bracketTaskMatch = line.match(/^- \*\*\[([^\]\n]+)\]\*\*\s+(.+)$/);
80
+ if (bracketTaskMatch) {
81
+ if (current) tasks.push(current);
82
+ const { allTags, tag } = tagsFromText(bracketTaskMatch[2]);
83
+ current = {
84
+ id: bracketTaskMatch[1],
85
+ title: cleanTaskTitle(bracketTaskMatch[2]),
86
+ tag,
87
+ tags: allTags,
88
+ claimed: null,
89
+ stage: null,
90
+ verify: null,
91
+ };
92
+ continue;
93
+ }
94
+
69
95
  const checkMatch = line.match(/^- \[[ x]\]\s+(.+)$/);
70
96
  if (checkMatch) {
71
97
  if (current) {