atris 3.16.1 → 3.17.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 (58) hide show
  1. package/README.md +32 -7
  2. package/atris/skills/atris/SKILL.md +15 -2
  3. package/atris/skills/atris-feedback/SKILL.md +7 -0
  4. package/atris/skills/design/SKILL.md +29 -2
  5. package/atris/skills/engines/SKILL.md +44 -0
  6. package/atris/skills/flow/SKILL.md +1 -1
  7. package/atris/skills/wake/SKILL.md +37 -0
  8. package/atris/skills/youtube/SKILL.md +13 -39
  9. package/atris/team/validator/MEMBER.md +1 -0
  10. package/atris/wiki/concepts/agent-activation-contract.md +3 -3
  11. package/atris/wiki/concepts/workspace-initialization-contract.md +3 -3
  12. package/atris/wiki/index.md +1 -0
  13. package/atris.md +43 -19
  14. package/bin/atris.js +400 -30
  15. package/commands/agent-spawn.js +480 -0
  16. package/commands/analytics.js +6 -3
  17. package/commands/apps.js +11 -0
  18. package/commands/autopilot.js +42 -18
  19. package/commands/brain.js +74 -7
  20. package/commands/brainstorm.js +9 -58
  21. package/commands/clean.js +1 -4
  22. package/commands/compile.js +9 -4
  23. package/commands/console.js +8 -3
  24. package/commands/deck.js +135 -0
  25. package/commands/init.js +22 -11
  26. package/commands/lesson.js +76 -0
  27. package/commands/member.js +252 -48
  28. package/commands/mission.js +405 -13
  29. package/commands/now.js +4 -2
  30. package/commands/probe.js +105 -27
  31. package/commands/pulse.js +504 -0
  32. package/commands/radar.js +1 -0
  33. package/commands/recap.js +55 -25
  34. package/commands/run.js +615 -22
  35. package/commands/slop.js +173 -0
  36. package/commands/spaceship.js +39 -0
  37. package/commands/sync.js +0 -2
  38. package/commands/task.js +429 -37
  39. package/commands/verify.js +7 -3
  40. package/lib/activity-stream.js +166 -0
  41. package/lib/auto-accept-certified.js +23 -1
  42. package/lib/context-gatherer.js +170 -0
  43. package/lib/escape-regexp.js +13 -0
  44. package/lib/file-ops.js +6 -3
  45. package/lib/journal.js +1 -1
  46. package/lib/lesson-contradiction.js +113 -0
  47. package/lib/policy-lessons.js +3 -2
  48. package/lib/pulse.js +401 -0
  49. package/lib/runner-command.js +156 -0
  50. package/lib/slides-deck.js +236 -0
  51. package/lib/state-detection.js +1 -4
  52. package/lib/task-db.js +101 -4
  53. package/lib/task-proof.js +1 -1
  54. package/lib/todo-fallback.js +2 -1
  55. package/lib/todo-sections.js +33 -0
  56. package/package.json +1 -2
  57. package/utils/api.js +14 -2
  58. package/atris/atrisDev.md +0 -717
@@ -0,0 +1,236 @@
1
+ // Atris deck engine — turn a plain content spec into a premium, anti-slop
2
+ // Google Slides deck. Pure: spec -> batch-update requests (no network here).
3
+ //
4
+ // The product idea: PMs open Slides and get Arial-on-white slop by default.
5
+ // This engine gives them a described deck rendered in a committed design system
6
+ // (own backgrounds, distinctive fonts, one accent, real data panels). It is
7
+ // built so it CANNOT emit the usual AI tells: em dashes are sanitized, labels
8
+ // stay sentence case, no gradient text, no glassmorphism, one accent hue.
9
+ //
10
+ // Spec shape (see commands/deck.js for the CLI):
11
+ // { theme: 'terminal'|'paper',
12
+ // brand: { name: 'Sentinel', accent: '.' },
13
+ // slides: [ { type, ...fields } ] }
14
+ // Emphasis: wrap a phrase in **double asterisks** to render it in the accent.
15
+
16
+ // ---------- themes (OKLCH design system, flattened to sRGB hex) ----------
17
+ const THEMES = {
18
+ terminal: { // warm dark "premium terminal"
19
+ fonts: { display: 'Fraunces', body: 'Outfit', mono: 'Roboto Mono' },
20
+ color: { bg: '#1E1A16', panel: '#2A231C', panelAlt: '#2F271F', line: '#3A332B',
21
+ ink: '#ECE6DD', soft: '#BCB2A4', faint: '#968C7E',
22
+ accent: '#D98E5C', accent2: '#E3A06B', onAccent: '#1E1A16',
23
+ sev: ['#D98E5C', '#DBBE84', '#7F97A4'] },
24
+ },
25
+ paper: { // light "editorial paper instrument"
26
+ fonts: { display: 'Fraunces', body: 'Outfit', mono: 'Roboto Mono' },
27
+ color: { bg: '#FBF8F2', panel: '#FFFFFF', panelAlt: '#F4EEE4', line: '#E5DDCF',
28
+ ink: '#2B241B', soft: '#6B5F4F', faint: '#877B69',
29
+ accent: '#B5572E', accent2: '#9A4723', onAccent: '#FFFFFF',
30
+ sev: ['#B5572E', '#C0883A', '#5F7787'] },
31
+ },
32
+ };
33
+ const COLOR_ROLES = ['bg', 'panel', 'line', 'ink', 'soft', 'faint', 'accent', 'accent2', 'onAccent'];
34
+
35
+ const W = 720, H = 405, M = 48; // slide is 720 x 405 PT
36
+
37
+ // ---------- low-level builder ----------
38
+ function rgb(hex) { const n = parseInt(String(hex).slice(1), 16);
39
+ return { red: ((n >> 16) & 255) / 255, green: ((n >> 8) & 255) / 255, blue: (n & 255) / 255 }; }
40
+
41
+ // strip the AI tells the engine refuses to ship. Returns sanitized text.
42
+ function sanitize(t) {
43
+ return String(t == null ? '' : t)
44
+ .replace(/\s*[—]\s*/g, ', ') // em dash -> comma (top AI-writing tell)
45
+ .replace(/\s-\s/g, ', ') // spaced hyphen used as a dash
46
+ .replace(/\bAI-powered\b/gi, 'built for')
47
+ .replace(/\s{2,}/g, ' ');
48
+ }
49
+
50
+ // parse **emphasis** -> { plain, ranges:[{start,end}] } (indices into plain)
51
+ function parseEmph(text) {
52
+ const ranges = []; let plain = ''; let i = 0;
53
+ while (i < text.length) {
54
+ if (text[i] === '*' && text[i + 1] === '*') {
55
+ const close = text.indexOf('**', i + 2);
56
+ if (close !== -1) { const inner = text.slice(i + 2, close);
57
+ ranges.push({ start: plain.length, end: plain.length + inner.length });
58
+ plain += inner; i = close + 2; continue; }
59
+ }
60
+ plain += text[i]; i++;
61
+ }
62
+ return { plain, ranges };
63
+ }
64
+
65
+ function makeCtx(theme) {
66
+ const C = theme.color, F = theme.fonts, requests = [];
67
+ let uid = 0; const nid = (p) => `${p}_${String(++uid).padStart(4, '0')}`;
68
+
69
+ const createSlide = (id) => requests.push({ createSlide: { objectId: id, slideLayoutReference: { predefinedLayout: 'BLANK' } } });
70
+ const bg = (slide, hex) => requests.push({ updatePageProperties: { objectId: slide,
71
+ pageProperties: { pageBackgroundFill: { solidFill: { color: { rgbColor: rgb(hex) } } } },
72
+ fields: 'pageBackgroundFill.solidFill.color' } });
73
+ const shape = (type, slide, x, y, w, h) => { const id = nid(type.slice(0, 2).toLowerCase());
74
+ requests.push({ createShape: { objectId: id, shapeType: type, elementProperties: {
75
+ pageObjectId: slide, size: { width: { magnitude: w, unit: 'PT' }, height: { magnitude: h, unit: 'PT' } },
76
+ transform: { scaleX: 1, scaleY: 1, translateX: x, translateY: y, unit: 'PT' } } } }); return id; };
77
+ const fill = (id, hex, outlineHex, weight) => {
78
+ const props = { shapeBackgroundFill: { solidFill: { color: { rgbColor: rgb(hex) } } } };
79
+ let fields = 'shapeBackgroundFill.solidFill.color';
80
+ if (outlineHex) { props.outline = { outlineFill: { solidFill: { color: { rgbColor: rgb(outlineHex) } } }, weight: { magnitude: weight || 1, unit: 'PT' } }; fields += ',outline.outlineFill.solidFill.color,outline.weight'; }
81
+ else { props.outline = { propertyState: 'NOT_RENDERED' }; fields += ',outline.propertyState'; }
82
+ requests.push({ updateShapeProperties: { objectId: id, shapeProperties: props, fields } }); return id; };
83
+ function styleRange(id, s, e, o) { if (e <= s || !o) return;
84
+ const style = {}, f = [];
85
+ if (o.family) { style.fontFamily = o.family; f.push('fontFamily'); }
86
+ if (o.size) { style.fontSize = { magnitude: o.size, unit: 'PT' }; f.push('fontSize'); }
87
+ if (o.color) { style.foregroundColor = { opaqueColor: { rgbColor: rgb(o.color) } }; f.push('foregroundColor'); }
88
+ if (o.bold) { style.bold = true; f.push('bold'); }
89
+ if (o.italic) { style.italic = true; f.push('italic'); }
90
+ if (!f.length) return;
91
+ requests.push({ updateTextStyle: { objectId: id, textRange: { type: 'FIXED_RANGE', startIndex: s, endIndex: e }, style, fields: f.join(',') } }); }
92
+ function box(slide, x, y, w, h, markup, opts = {}) {
93
+ const { plain, ranges } = parseEmph(sanitize(markup));
94
+ if (!plain.length) return null;
95
+ const id = shape('TEXT_BOX', slide, x, y, w, h);
96
+ requests.push({ insertText: { objectId: id, text: plain } });
97
+ styleRange(id, 0, plain.length, opts);
98
+ const accentColor = opts.accent || C.accent2;
99
+ ranges.forEach((r) => styleRange(id, r.start, r.end, { family: opts.family, size: opts.size, color: accentColor, italic: opts.emphItalic }));
100
+ if (opts.align || opts.line != null) requests.push({ updateParagraphStyle: { objectId: id, textRange: { type: 'ALL' },
101
+ style: { ...(opts.align ? { alignment: opts.align } : {}), ...(opts.line != null ? { lineSpacing: opts.line } : {}) },
102
+ fields: [opts.align && 'alignment', opts.line != null && 'lineSpacing'].filter(Boolean).join(',') } });
103
+ if (opts.vmid) requests.push({ updateShapeProperties: { objectId: id, shapeProperties: { contentAlignment: 'MIDDLE' }, fields: 'contentAlignment' } });
104
+ return id; }
105
+ const rule = (slide, x, y, w, hex) => fill(shape('RECTANGLE', slide, x, y, w, 2), hex || C.accent);
106
+
107
+ function wordmark(slide, x, y, size, brand, center) {
108
+ const name = (brand && brand.name) || 'Atris';
109
+ const ac = (brand && brand.accent) || '';
110
+ const id = box(slide, x, y, center ? W - x * 2 : size * 9, size * 1.6, name + ac,
111
+ { family: F.display, size, color: C.ink, align: center ? 'CENTER' : 'START' });
112
+ if (ac) styleRange(id, name.length, name.length + ac.length, { family: F.display, size, color: C.accent });
113
+ return id; }
114
+
115
+ // generalized data panel: header + rows + footer
116
+ function panel(slide, x, y, w, data) {
117
+ const rows = (data.rows || []).slice(0, 4);
118
+ const rowH = 38, headH = data.header ? 30 : 0, footH = data.footer ? 26 : 0;
119
+ const h = headH + rowH * rows.length + footH;
120
+ fill(shape('ROUND_RECTANGLE', slide, x, y, w, h), C.panel, C.line, 1);
121
+ if (data.header) {
122
+ box(slide, x + 14, y + 9, w * 0.6, 16, data.header.title || '', { family: F.body, size: 10.5, color: C.ink });
123
+ if (data.header.meta) box(slide, x + w - 120, y + 9, 106, 14, data.header.meta, { family: F.body, size: 8.5, color: C.faint, align: 'END' });
124
+ fill(shape('RECTANGLE', slide, x, y + headH, w, 0.75), C.line);
125
+ }
126
+ rows.forEach((r, i) => {
127
+ const ry = y + headH + i * rowH;
128
+ if (r.active) fill(shape('RECTANGLE', slide, x, ry, 2, rowH), C.accent);
129
+ const sev = C.sev[(r.sev != null ? r.sev : 0) % C.sev.length];
130
+ fill(shape('ELLIPSE', slide, x + 16, ry + rowH / 2 - 3.5, 7, 7), sev);
131
+ const nameTxt = sanitize(r.title || '') + (r.sub ? '\n' + sanitize(r.sub) : '');
132
+ const nb = box(slide, x + 30, ry + 6, w - 110, 28, nameTxt, { family: F.body, size: 11, color: C.ink, line: 108 });
133
+ if (r.sub && nb) styleRange(nb, sanitize(r.title || '').length + 1, nameTxt.length, { family: F.body, size: 9, color: C.faint });
134
+ if (r.value != null) {
135
+ const valTxt = String(r.value) + (r.valueSub ? '\n' + r.valueSub : '');
136
+ const bb = box(slide, x + w - 84, ry + 6, 70, 28, valTxt, { family: F.body, size: 11, color: C.ink, align: 'END' });
137
+ if (bb) { styleRange(bb, 0, String(r.value).length, { family: F.body, size: 13, color: C.ink, bold: true });
138
+ if (r.valueSub) styleRange(bb, String(r.value).length + 1, valTxt.length, { family: F.body, size: 8, color: C.faint }); }
139
+ }
140
+ if (i < rows.length - 1) fill(shape('RECTANGLE', slide, x, ry + rowH, w, 0.75), C.panelAlt);
141
+ });
142
+ if (data.footer) { const fy = y + headH + rowH * rows.length;
143
+ box(slide, x + 14, fy + 6, w * 0.62, 14, data.footer.left || '', { family: F.body, size: 9, color: C.faint });
144
+ if (data.footer.right) box(slide, x + w - 84, fy + 6, 70, 14, data.footer.right, { family: F.body, size: 9, color: C.accent2, align: 'END' }); }
145
+ return h; }
146
+
147
+ function chips(slide, x, y, list) { let cx = x;
148
+ (list || []).forEach((label) => { const t = sanitize(label); const w = 16 + t.length * 6.3;
149
+ fill(shape('ROUND_RECTANGLE', slide, cx, y, w, 24), C.panel, C.line, 1);
150
+ box(slide, cx, y, w, 24, t, { family: F.mono, size: 9.5, color: C.faint, align: 'CENTER', vmid: true });
151
+ cx += w + 10; }); }
152
+
153
+ function buttons(slide, y, list) {
154
+ const items = (list || []).slice(0, 3); const bw = 150, bh = 34, gap = 12;
155
+ const total = items.length * bw + (items.length - 1) * gap; let bx = (W - total) / 2;
156
+ items.forEach((b) => { const primary = b.primary;
157
+ fill(shape('ROUND_RECTANGLE', slide, bx, y, bw, bh), primary ? C.ink : C.bg, primary ? null : C.line, 1);
158
+ box(slide, bx, y, bw, bh, b.label || 'Button', { family: F.body, size: 12.5, color: primary ? C.bg : C.ink, align: 'CENTER', vmid: true });
159
+ bx += bw + gap; }); }
160
+
161
+ return { requests, C, F, nid, createSlide, bg, shape, fill, box, styleRange, rule, wordmark, panel, chips, buttons };
162
+ }
163
+
164
+ // ---------- slide archetypes ----------
165
+ const ARCHE = {
166
+ title(ctx, slide, s, spec) {
167
+ ctx.wordmark(slide, M, 34, 15, spec.brand);
168
+ ctx.rule(slide, M, 96, 40);
169
+ const hasPanel = !!s.panel;
170
+ ctx.box(slide, M, 110, hasPanel ? 360 : 600, 180, s.headline || s.title || '',
171
+ { family: ctx.F.display, size: 40, color: ctx.C.ink, line: 100, emphItalic: true });
172
+ if (s.sub) ctx.box(slide, M, hasPanel ? 300 : 300, hasPanel ? 350 : 540, 70, s.sub, { family: ctx.F.body, size: 12.5, color: ctx.C.soft, line: 120 });
173
+ if (hasPanel) ctx.panel(slide, 432, 96, 240, s.panel);
174
+ },
175
+ statement(ctx, slide, s, spec) {
176
+ ctx.wordmark(slide, M, 34, 13, spec.brand);
177
+ ctx.rule(slide, M, 150, 40);
178
+ ctx.box(slide, M, 164, 600, 120, s.text || s.headline || '', { family: ctx.F.display, size: 46, color: ctx.C.ink, line: 100, emphItalic: true });
179
+ if (s.sub) ctx.box(slide, M, 286, 480, 70, s.sub, { family: ctx.F.body, size: 14, color: ctx.C.soft, line: 130 });
180
+ },
181
+ columns(ctx, slide, s, spec) {
182
+ ctx.wordmark(slide, M, 34, 13, spec.brand);
183
+ if (s.heading) ctx.box(slide, M, 110, 560, 34, s.heading, { family: ctx.F.display, size: 26, color: ctx.C.ink });
184
+ const cols = (s.columns || []).slice(0, 4); const n = cols.length || 1;
185
+ const span = W - M * 2, gap = 16, colW = (span - (n - 1) * gap) / n, cy = s.heading ? 188 : 150;
186
+ cols.forEach((c, i) => { const cx = M + i * (colW + gap);
187
+ if (i > 0) ctx.fill(ctx.shape('RECTANGLE', slide, cx - gap / 2, cy, 0.75, 120), ctx.C.line);
188
+ ctx.box(slide, cx, cy, colW - 8, 30, c.h || c.title || '', { family: ctx.F.display, size: 17, color: ctx.C.ink });
189
+ ctx.box(slide, cx, cy + 34, colW - 8, 120, c.b || c.body || '', { family: ctx.F.body, size: 11.5, color: ctx.C.soft, line: 134 }); });
190
+ },
191
+ panel(ctx, slide, s, spec) {
192
+ ctx.wordmark(slide, M, 34, 13, spec.brand);
193
+ if (s.heading) ctx.box(slide, M, 120, 260, 50, s.heading, { family: ctx.F.display, size: 30, color: ctx.C.ink });
194
+ if (s.sub) ctx.box(slide, M, 178, 260, 160, s.sub, { family: ctx.F.body, size: 12.5, color: ctx.C.soft, line: 132 });
195
+ ctx.panel(slide, 360, 110, 312, s.panel || { rows: [] });
196
+ },
197
+ chips(ctx, slide, s, spec) {
198
+ ctx.wordmark(slide, M, 34, 13, spec.brand);
199
+ if (s.heading) ctx.box(slide, M, 110, 580, 34, s.heading, { family: ctx.F.display, size: 28, color: ctx.C.ink });
200
+ if (s.sub) ctx.box(slide, M, 162, 480, 64, s.sub, { family: ctx.F.body, size: 12.5, color: ctx.C.soft, line: 132 });
201
+ ctx.chips(slide, M, 250, s.chips || []);
202
+ if (s.mono) ctx.box(slide, M, 300, 520, 24, s.mono, { family: ctx.F.mono, size: 12, color: ctx.C.accent2 });
203
+ },
204
+ bignumber(ctx, slide, s, spec) {
205
+ ctx.wordmark(slide, M, 34, 13, spec.brand);
206
+ ctx.box(slide, M, 138, W - M * 2, 120, s.number || s.value || '', { family: ctx.F.display, size: 92, color: ctx.C.accent2, line: 100 });
207
+ if (s.label) ctx.box(slide, M, 268, 520, 30, s.label, { family: ctx.F.body, size: 16, color: ctx.C.ink });
208
+ if (s.sub) ctx.box(slide, M, 300, 480, 50, s.sub, { family: ctx.F.body, size: 12.5, color: ctx.C.soft, line: 130 });
209
+ },
210
+ close(ctx, slide, s, spec) {
211
+ ctx.rule(slide, (W - 48) / 2, 150, 48);
212
+ const name = (spec.brand && spec.brand.name) || 'Atris';
213
+ const ac = (spec.brand && spec.brand.accent) || '';
214
+ const id = ctx.box(slide, 0, 168, W, 64, name + ac, { family: ctx.F.display, size: 52, color: ctx.C.ink, align: 'CENTER' });
215
+ if (ac && id) ctx.styleRange(id, name.length, name.length + ac.length, { family: ctx.F.display, size: 52, color: ctx.C.accent });
216
+ if (s.tagline) ctx.box(slide, 0, 244, W, 24, s.tagline, { family: ctx.F.body, size: 14, color: ctx.C.soft, align: 'CENTER' });
217
+ if (s.buttons) ctx.buttons(slide, 296, s.buttons);
218
+ if (s.footer) ctx.box(slide, 0, 360, W, 20, s.footer, { family: ctx.F.body, size: 10, color: ctx.C.faint, align: 'CENTER' });
219
+ },
220
+ };
221
+
222
+ // ---------- public: spec -> requests ----------
223
+ function buildDeck(spec) {
224
+ const theme = THEMES[spec.theme] || THEMES.terminal;
225
+ const ctx = makeCtx(theme);
226
+ const slideIds = [];
227
+ (spec.slides || []).forEach((s, i) => {
228
+ const sid = `deck_slide_${i + 1}`; slideIds.push(sid);
229
+ ctx.createSlide(sid);
230
+ ctx.bg(sid, theme.color.bg);
231
+ (ARCHE[s.type] || ARCHE.statement)(ctx, sid, s, spec);
232
+ });
233
+ return { requests: ctx.requests, slideIds };
234
+ }
235
+
236
+ module.exports = { buildDeck, THEMES, ARCHE, sanitize, parseEmph, rgb, COLOR_ROLES };
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const escapeRegExp = require('./escape-regexp');
3
4
 
4
5
  const TASK_STATUS_BUCKETS = {
5
6
  backlog: new Set(['open']),
@@ -184,10 +185,6 @@ function getTaskCounts(atrisDir) {
184
185
  };
185
186
  }
186
187
 
187
- function escapeRegExp(value) {
188
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
189
- }
190
-
191
188
  function getTodayInboxItems(workspaceDir) {
192
189
  const atrisDir = path.join(workspaceDir, 'atris');
193
190
  const logsDir = path.join(atrisDir, 'logs');
package/lib/task-db.js CHANGED
@@ -59,6 +59,95 @@ const TASK_PLAN_TAGS = new Set([
59
59
  'ux',
60
60
  ]);
61
61
 
62
+ function todayLogName() {
63
+ const now = new Date();
64
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}.md`;
65
+ }
66
+
67
+ function compactLogText(value, max = 240) {
68
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
69
+ if (!text) return '';
70
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 3)).trim()}...` : text;
71
+ }
72
+
73
+ function logFieldRows(fields) {
74
+ return Object.entries(fields)
75
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
76
+ .map(([key, value]) => `- ${key}: ${compactLogText(value, 500)}`);
77
+ }
78
+
79
+ function taskMemberCandidates(row, actor) {
80
+ const metadata = row && row.metadata && typeof row.metadata === 'object' ? row.metadata : {};
81
+ return [
82
+ metadata.assigned_to,
83
+ metadata.stage_owner,
84
+ metadata.planned_by,
85
+ metadata.agent_certified_by,
86
+ row && row.claimed_by,
87
+ actor,
88
+ ].map(value => String(value || '').trim())
89
+ .filter(Boolean)
90
+ .filter((value, index, list) => list.indexOf(value) === index);
91
+ }
92
+
93
+ function existingMemberSlug(workspaceRoot, row, actor) {
94
+ for (const candidate of taskMemberCandidates(row, actor)) {
95
+ if (!/^[a-zA-Z0-9._-]+$/.test(candidate)) continue;
96
+ const memberFile = path.join(workspaceRoot, 'atris', 'team', candidate, 'MEMBER.md');
97
+ if (fs.existsSync(memberFile)) return candidate;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function appendTaskCompletionLogs(db, row, { status, actor, action, proof } = {}) {
103
+ if (!row || !row.workspace_root || !fs.existsSync(path.join(row.workspace_root, 'atris'))) return {};
104
+ const logName = todayLogName();
105
+ const stamp = new Date().toTimeString().slice(0, 5);
106
+ const allRows = listTasks(db, { workspaceRoot: row.workspace_root });
107
+ const ref = taskDisplayRefMap(allRows).get(row.id) || shortestUniqueTaskRef(row.id, allRows.map(task => task.id), 8) || row.id;
108
+ const metadata = row.metadata && typeof row.metadata === 'object' ? row.metadata : {};
109
+ const proofText = compactLogText(proof || metadata.latest_agent_proof || metadata.verify || '', 500);
110
+ const member = existingMemberSlug(row.workspace_root, row, actor);
111
+ const title = status === 'failed' ? 'Task failed' : action === 'accepted' ? 'Task accepted' : 'Task completed';
112
+ const fields = {
113
+ task: ref,
114
+ title: row.title,
115
+ status,
116
+ action,
117
+ tag: row.tag,
118
+ member,
119
+ actor,
120
+ proof: proofText,
121
+ };
122
+
123
+ const projectDir = path.join(row.workspace_root, 'atris', 'logs', logName.slice(0, 4));
124
+ fs.mkdirSync(projectDir, { recursive: true });
125
+ const projectLogPath = path.join(projectDir, logName);
126
+ fs.appendFileSync(projectLogPath, [
127
+ `## ${stamp} · ${title}`,
128
+ ...logFieldRows(fields),
129
+ '',
130
+ ].join('\n'), 'utf8');
131
+
132
+ let memberLogPath = null;
133
+ if (member) {
134
+ const memberLogsDir = path.join(row.workspace_root, 'atris', 'team', member, 'logs');
135
+ fs.mkdirSync(memberLogsDir, { recursive: true });
136
+ memberLogPath = path.join(memberLogsDir, logName);
137
+ fs.appendFileSync(memberLogPath, [
138
+ `## ${stamp} · ${title}`,
139
+ ...logFieldRows({ ...fields, member }),
140
+ '',
141
+ ].join('\n'), 'utf8');
142
+ }
143
+
144
+ return {
145
+ project_log_path: projectLogPath,
146
+ member_log_path: memberLogPath,
147
+ member,
148
+ };
149
+ }
150
+
62
151
  const SCHEMA = `
63
152
  CREATE TABLE IF NOT EXISTS tasks (
64
153
  id TEXT PRIMARY KEY,
@@ -325,6 +414,7 @@ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
325
414
  ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
326
415
  ORDER BY
327
416
  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,
417
+ CASE WHEN tag='endgame' THEN 0 ELSE 1 END,
328
418
  created_at DESC
329
419
  ${limit ? 'LIMIT ' + Number(limit) : ''}
330
420
  `;
@@ -379,7 +469,7 @@ function claimTask(db, { id, claimedBy }) {
379
469
  return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
380
470
  }
381
471
 
382
- function doneTask(db, { id, status, actor, allowReview = false }) {
472
+ function doneTask(db, { id, status, actor, allowReview = false, action, proof } = {}) {
383
473
  if (!id) throw new Error('id required');
384
474
  const final = status || 'done';
385
475
  if (!['done', 'failed'].includes(final)) throw new Error('status must be done|failed');
@@ -392,16 +482,23 @@ function doneTask(db, { id, status, actor, allowReview = false }) {
392
482
  AND status IN (${allowedStatuses})
393
483
  `).run(final, now, now, id));
394
484
  if (result.changes === 1) {
395
- const row = db.prepare('SELECT id, workspace_root FROM tasks WHERE id = ?').get(id);
485
+ const row = getTask(db, id);
396
486
  appendTaskEvent(db, {
397
487
  taskId: id,
398
488
  workspaceRoot: row.workspace_root,
399
489
  actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
400
490
  eventType: final === 'done' ? 'completed' : 'blocked',
401
- payload: { status: final },
491
+ payload: { status: final, action: action || final },
492
+ });
493
+ const logs = appendTaskCompletionLogs(db, row, {
494
+ status: final,
495
+ actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
496
+ action: action || final,
497
+ proof,
402
498
  });
499
+ return { updated: true, logs };
403
500
  }
404
- return { updated: result.changes === 1 };
501
+ return { updated: false };
405
502
  }
406
503
 
407
504
  function readyTask(db, { id, actor, proof, lesson, nextTask }) {
package/lib/task-proof.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const GENERIC_COMPLETION_PROOF_RE = /^(?:done|done now|complete|completed|finished|fixed|handled|ship|shipped|ok|okay|yes|yep|looks good|looks good to me|all set|should be good|works now|approved|approve|lgtm|failed)$/i;
4
4
 
5
- const COMMAND_PROOF_RE = /\b(?:npm\s+run|npm\s+test|node\s+--test|node\s+scripts\/|pnpm\b|yarn\b|npx\b|pytest\b|python\s+-m|tsc\b|vite\s+build|git\s+diff\s+--check|curl\b|atris\s+task|\.\/ax\b|ax\s+--|test\s+-s)\b/i;
5
+ const COMMAND_PROOF_RE = /\b(?:npm\s+run|npm\s+test|node\s+--test|node\s+scripts\/|pnpm\b|yarn\b|npx\b|pytest\b|python\s+-m|tsc\b|vite\s+build|git\s+diff\s+--(?:check|exit-code|quiet)|grep\s+-[A-Za-z]*q[A-Za-z]*|rg\s+(?:-\S+\s+)*(?:"[^"]+"|'[^']+'|\S+)\s+(?:\.{0,2}\/|~\/|\/|[\w.-]+\/|[\w.-]+\.[A-Za-z0-9]|\b(?:atris|bin|commands|lib|scripts|src|test)\b)|diff\s+(?:-u|--brief)|cmp\s+-s|curl\b|atris\s+task|\.\/ax\b|ax\s+--|test\s+-s)\b/i;
6
6
  const FILE_PROOF_RE = /(?:^|[\s'"`])(?:\.{0,2}\/|~\/|\/Users\/|src\/|scripts\/|atris\/|backend\/|public\/|resources\/|package[.]json|main[.]js|preload[.]js|AGENTXP_PROOF[.]md)[^\s'"`,;)]*/i;
7
7
  const PATH_ONLY_PROOF_RE = /(?:^|[\s'"`])(?:\.{0,2}\/|~\/|\/Users\/|\/private\/|\/var\/|atris\/runs\/|\.atris\/state\/)[^\s'"`,;)]+(?:[.](?:json|jsonl|md|log|txt|png|jpg|jpeg|pdf))?/i;
8
8
  const RECEIPT_OR_ARTIFACT_RE = /\b(?:receipt|artifact|screenshot|log|trace|path=|file=|bytes=|model=|opened=|https?:\/\/)\b/i;
@@ -8,6 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
+ const escapeRegExp = require('./escape-regexp');
11
12
 
12
13
  function parseTodoFile(todoPath) {
13
14
  if (!fs.existsSync(todoPath)) return { backlog: [], inProgress: [], review: [], completed: [] };
@@ -37,7 +38,7 @@ function cleanTaskTitle(text) {
37
38
  }
38
39
 
39
40
  function parseSection(content, sectionName) {
40
- const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ const escaped = escapeRegExp(sectionName);
41
42
  const match = content.match(new RegExp(`(?:^|\\n)##\\s+${escaped}[^\\n]*\\n([\\s\\S]*?)(?=\\n##(?!#)\\s+|$)`, 'i'));
42
43
  if (!match) return [];
43
44
 
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ // Canonical TODO/journal section classification. The same emoji-brittle logic
4
+ // existed (and was bug-fixed) independently in commands/now.js (CLI-287) and
5
+ // commands/brain.js (CLI-288); centralizing it here so a third copy can't drift.
6
+ //
7
+ // Headings may carry trailing decoration ("## In Progress 🔄", "## Completed ✅"),
8
+ // so detection uses \b (not \s*$) and section names match by prefix — "In Progress 🔄"
9
+ // counts as "In Progress", while "Backlogged Notes" does NOT count as "Backlog".
10
+
11
+ const RENDERED_SECTION_RE = /^##\s+(Backlog|In Progress|Blocked|Completed)\b/m;
12
+
13
+ // True when the document uses rendered task sections (vs a flat/legacy bullet list).
14
+ function hasRenderedSections(text) {
15
+ return RENDERED_SECTION_RE.test(String(text || ''));
16
+ }
17
+
18
+ // A parsed heading name matches a canonical section if it equals it or is that
19
+ // name followed by decoration (a space then emoji/text).
20
+ function sectionMatches(name, target) {
21
+ const n = String(name || '');
22
+ return n === target || n.startsWith(`${target} `);
23
+ }
24
+
25
+ function isOpenSection(name) {
26
+ return sectionMatches(name, 'Backlog') || sectionMatches(name, 'In Progress');
27
+ }
28
+
29
+ function isDoneSection(name) {
30
+ return sectionMatches(name, 'Completed');
31
+ }
32
+
33
+ module.exports = { hasRenderedSections, sectionMatches, isOpenSection, isDoneSection, RENDERED_SECTION_RE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.16.1",
3
+ "version": "3.17.0",
4
4
  "main": "bin/atris.js",
5
5
  "bin": {
6
6
  "atris": "bin/atris.js",
@@ -19,7 +19,6 @@
19
19
  "atris.md",
20
20
  "GETTING_STARTED.md",
21
21
  "PERSONA.md",
22
- "atris/atrisDev.md",
23
22
  "atris/CLAUDE.md",
24
23
  "atris/GEMINI.md",
25
24
  "atris/GETTING_STARTED.md",
package/utils/api.js CHANGED
@@ -216,6 +216,7 @@ function streamProChat(url, token, body, showTools = false) {
216
216
  }
217
217
 
218
218
  let buffer = '';
219
+ let emittedText = false;
219
220
 
220
221
  res.on('data', (chunk) => {
221
222
  buffer += chunk.toString();
@@ -232,10 +233,16 @@ function streamProChat(url, token, body, showTools = false) {
232
233
 
233
234
  if (msg.type === 'system_init' && showTools) {
234
235
  console.log(`[System] Tools available: ${msg.tools?.join(', ') || 'none'}`);
236
+ } else if (msg.type === 'text_delta') {
237
+ if (msg.content) {
238
+ emittedText = true;
239
+ process.stdout.write(msg.content);
240
+ }
235
241
  } else if (msg.type === 'assistant') {
236
242
  if (msg.content && Array.isArray(msg.content)) {
237
243
  for (const block of msg.content) {
238
244
  if (block.type === 'text') {
245
+ emittedText = true;
239
246
  process.stdout.write(block.text);
240
247
  }
241
248
  }
@@ -245,11 +252,14 @@ function streamProChat(url, token, body, showTools = false) {
245
252
  } else if (msg.type === 'tool_result' && showTools) {
246
253
  const preview = msg.content?.substring(0, 100) || '';
247
254
  console.log(`[✓ Result]: ${preview}${msg.content?.length > 100 ? '...' : ''}`);
248
- } else if (msg.type === 'result') {
255
+ } else if (msg.type === 'result' && !emittedText) {
249
256
  if (msg.result) {
250
257
  process.stdout.write(msg.result);
251
258
  }
259
+ } else if (msg.type === 'error') {
260
+ reject(new Error(msg.error || 'Atris stream error'));
252
261
  } else if (msg.chunk) {
262
+ emittedText = true;
253
263
  process.stdout.write(msg.chunk);
254
264
  }
255
265
  } catch (e) {
@@ -270,7 +280,9 @@ function streamProChat(url, token, body, showTools = false) {
270
280
  const msg = JSON.parse(data);
271
281
  if (msg.chunk) {
272
282
  process.stdout.write(msg.chunk);
273
- } else if (msg.type === 'result' && msg.result) {
283
+ } else if (msg.type === 'text_delta' && msg.content) {
284
+ process.stdout.write(msg.content);
285
+ } else if (msg.type === 'result' && msg.result && !emittedText) {
274
286
  process.stdout.write(msg.result);
275
287
  }
276
288
  } catch (e) {