atris 3.16.1 → 3.22.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 +32 -7
- package/atris/skills/atris/SKILL.md +15 -2
- package/atris/skills/atris-feedback/SKILL.md +7 -0
- package/atris/skills/design/SKILL.md +29 -2
- package/atris/skills/engines/SKILL.md +44 -0
- package/atris/skills/flow/SKILL.md +1 -1
- package/atris/skills/wake/SKILL.md +37 -0
- package/atris/skills/youtube/SKILL.md +13 -39
- package/atris/team/validator/MEMBER.md +1 -0
- package/atris/wiki/concepts/agent-activation-contract.md +3 -3
- package/atris/wiki/concepts/workspace-initialization-contract.md +3 -3
- package/atris/wiki/index.md +1 -0
- package/atris.md +43 -19
- package/bin/atris.js +413 -31
- package/commands/agent-spawn.js +480 -0
- package/commands/analytics.js +6 -3
- package/commands/apps.js +11 -0
- package/commands/autopilot.js +42 -18
- package/commands/brain.js +74 -7
- package/commands/brainstorm.js +9 -58
- package/commands/clean.js +1 -4
- package/commands/compile.js +9 -4
- package/commands/console.js +8 -3
- package/commands/deck.js +184 -0
- package/commands/init.js +22 -11
- package/commands/lesson.js +76 -0
- package/commands/member.js +252 -48
- package/commands/mission.js +405 -13
- package/commands/now.js +4 -2
- package/commands/probe.js +105 -27
- package/commands/pulse.js +504 -0
- package/commands/radar.js +1 -0
- package/commands/recap.js +71 -25
- package/commands/run.js +615 -22
- package/commands/site.js +48 -0
- package/commands/slop.js +307 -0
- package/commands/spaceship.js +39 -0
- package/commands/sync.js +0 -2
- package/commands/task.js +429 -37
- package/commands/theme.js +217 -0
- package/commands/verify.js +7 -3
- package/lib/activity-stream.js +166 -0
- package/lib/auto-accept-certified.js +23 -1
- package/lib/context-gatherer.js +170 -0
- package/lib/deck-from-md.js +110 -0
- package/lib/escape-regexp.js +13 -0
- package/lib/file-ops.js +6 -3
- package/lib/html-render.js +257 -0
- package/lib/journal.js +1 -1
- package/lib/lesson-contradiction.js +113 -0
- package/lib/memory-view.js +95 -0
- package/lib/policy-lessons.js +3 -2
- package/lib/pulse.js +401 -0
- package/lib/runner-command.js +156 -0
- package/lib/site.js +114 -0
- package/lib/slides-deck.js +237 -0
- package/lib/state-detection.js +1 -4
- package/lib/task-db.js +101 -4
- package/lib/task-proof.js +1 -1
- package/lib/theme.js +264 -0
- package/lib/todo-fallback.js +2 -1
- package/lib/todo-sections.js +33 -0
- package/package.json +1 -2
- package/utils/api.js +14 -2
- package/atris/atrisDev.md +0 -717
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Markdown -> deck spec. Lets a PM write an ordinary doc and get a designed deck.
|
|
2
|
+
// Pure: a markdown string in, a { theme, brand, slides } spec out (fed to buildDeck).
|
|
3
|
+
//
|
|
4
|
+
// Mapping (predictable on purpose):
|
|
5
|
+
// --- front matter --- theme / brand / accent
|
|
6
|
+
// # H1 (first) -> title slide (sub = the paragraph under it)
|
|
7
|
+
// ## H2 with 2-4 bullets -> columns slide (each bullet "**Lead** rest" -> a column)
|
|
8
|
+
// ## H2 with "**X** label" only -> bignumber slide
|
|
9
|
+
// ## H2 titled Close/CTA/Thanks -> close slide
|
|
10
|
+
// ## H2 otherwise -> statement slide (sub = the paragraph under it)
|
|
11
|
+
// Emphasis (**bold**) is preserved and rendered in the accent by the engine.
|
|
12
|
+
|
|
13
|
+
function parseFrontMatter(md) {
|
|
14
|
+
const out = { theme: null, brand: null, accent: null };
|
|
15
|
+
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
16
|
+
if (!m) return { body: md, fm: out };
|
|
17
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
18
|
+
const kv = line.match(/^([A-Za-z_]+):\s*(.+?)\s*$/);
|
|
19
|
+
if (!kv) continue;
|
|
20
|
+
const k = kv[1].toLowerCase();
|
|
21
|
+
const v = kv[2].trim().replace(/^["']|["']$/g, '');
|
|
22
|
+
if (k === 'theme') out.theme = v;
|
|
23
|
+
else if (k === 'brand') out.brand = v;
|
|
24
|
+
else if (k === 'accent') out.accent = v;
|
|
25
|
+
}
|
|
26
|
+
return { body: md.slice(m[0].length), fm: out };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// group a section's lines into bullets[] and paragraphs[]
|
|
30
|
+
function splitBody(lines) {
|
|
31
|
+
const bullets = [];
|
|
32
|
+
const paras = [];
|
|
33
|
+
let buf = [];
|
|
34
|
+
const flush = () => { if (buf.length) { paras.push(buf.join(' ').trim()); buf = []; } };
|
|
35
|
+
for (const raw of lines) {
|
|
36
|
+
const line = raw.replace(/\s+$/, '');
|
|
37
|
+
if (/^\s*[-*+]\s+/.test(line)) { flush(); bullets.push(line.replace(/^\s*[-*+]\s+/, '').trim()); }
|
|
38
|
+
else if (/^\s*$/.test(line)) flush();
|
|
39
|
+
else if (/^\s*>\s+/.test(line)) { flush(); paras.push(line.replace(/^\s*>\s+/, '').trim()); }
|
|
40
|
+
else buf.push(line.trim());
|
|
41
|
+
}
|
|
42
|
+
flush();
|
|
43
|
+
return { bullets, paras: paras.filter(Boolean) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// "**Lead** rest" / "Lead: rest" / "Lead - rest" -> { h, b }
|
|
47
|
+
function toColumn(bullet) {
|
|
48
|
+
let m = bullet.match(/^\*\*(.+?)\*\*[\s:.-]*(.*)$/);
|
|
49
|
+
if (m) return { h: m[1].trim(), b: m[2].trim() };
|
|
50
|
+
m = bullet.match(/^([^:]{2,48}):\s+(.+)$/);
|
|
51
|
+
if (m) return { h: m[1].trim(), b: m[2].trim() };
|
|
52
|
+
return { h: bullet.trim(), b: '' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// a single "**value** label" paragraph -> big number slide
|
|
56
|
+
function asBigNumber(paras, bullets) {
|
|
57
|
+
if (bullets.length || paras.length !== 1) return null;
|
|
58
|
+
const m = paras[0].match(/^\*\*([^*]{1,18})\*\*\s+(.+)$/);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
return { type: 'bignumber', number: m[1].trim(), label: m[2].trim() };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CLOSE_TITLES = /^(close|thanks|thank you|cta|call to action|get started|wrap up|next steps?)$/i;
|
|
64
|
+
|
|
65
|
+
function parseMarkdownToSpec(md, opts = {}) {
|
|
66
|
+
const { body, fm } = parseFrontMatter(String(md || ''));
|
|
67
|
+
const theme = opts.theme || fm.theme || 'terminal';
|
|
68
|
+
const brand = {
|
|
69
|
+
name: opts.brandName || fm.brand || 'Atris',
|
|
70
|
+
accent: opts.accent || fm.accent || '.',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// collect sections by heading
|
|
74
|
+
const sections = [];
|
|
75
|
+
let cur = null;
|
|
76
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
77
|
+
const h1 = raw.match(/^#\s+(.+)/);
|
|
78
|
+
const h2 = raw.match(/^##\s+(.+)/);
|
|
79
|
+
if (h1) { cur = { level: 1, title: h1[1].trim(), lines: [] }; sections.push(cur); }
|
|
80
|
+
else if (h2) { cur = { level: 2, title: h2[1].trim(), lines: [] }; sections.push(cur); }
|
|
81
|
+
else if (cur) cur.lines.push(raw);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const slides = [];
|
|
85
|
+
sections.forEach((sec, idx) => {
|
|
86
|
+
const { bullets, paras } = splitBody(sec.lines);
|
|
87
|
+
const firstPara = paras[0] || '';
|
|
88
|
+
|
|
89
|
+
if (sec.level === 1 && slides.length === 0) {
|
|
90
|
+
slides.push({ type: 'title', headline: sec.title, sub: firstPara || undefined });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (CLOSE_TITLES.test(sec.title)) {
|
|
94
|
+
slides.push({ type: 'close', tagline: firstPara || sec.title, footer: bullets[0] || undefined });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const big = asBigNumber(paras, bullets);
|
|
98
|
+
if (big) { slides.push(big); return; }
|
|
99
|
+
if (bullets.length >= 2 && bullets.length <= 4 && bullets.every((b) => b.length <= 160)) {
|
|
100
|
+
slides.push({ type: 'columns', heading: sec.title, columns: bullets.map(toColumn) });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
slides.push({ type: 'statement', text: sec.title, sub: firstPara || undefined });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!slides.length) slides.push({ type: 'statement', text: brand.name });
|
|
107
|
+
return { theme, brand, slides };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { parseMarkdownToSpec, parseFrontMatter, splitBody, toColumn };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Canonical regex-metacharacter escaper. Embedding unescaped data (user input,
|
|
4
|
+
// task ids, member names, section headings) into `new RegExp(`...${x}...`)` is a
|
|
5
|
+
// recurring crash/mismatch bug in this codebase (CLI-257, CLI-258): a value like
|
|
6
|
+
// "(" throws an uncaught SyntaxError, and "a.b" silently wildcard-matches "aXb".
|
|
7
|
+
// Always wrap data-derived interpolations with this before building a RegExp.
|
|
8
|
+
// (Interpolating a hardcoded regex *fragment* is fine and does not need escaping.)
|
|
9
|
+
function escapeRegExp(value) {
|
|
10
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = escapeRegExp;
|
package/lib/file-ops.js
CHANGED
|
@@ -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
|
/**
|
|
5
6
|
* Get the path components for a journal log file.
|
|
@@ -61,7 +62,6 @@ function createLogFile(logFile, dateFormatted) {
|
|
|
61
62
|
if (fs.existsSync(prevLogFile)) {
|
|
62
63
|
const prevContent = fs.readFileSync(prevLogFile, 'utf8');
|
|
63
64
|
|
|
64
|
-
const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
65
65
|
const sectionBody = (headingLine) => {
|
|
66
66
|
const regex = new RegExp(
|
|
67
67
|
`## ${escapeRegExp(headingLine)}\\n([\\s\\S]*?)(?=\\n---|\\n## |$)`
|
|
@@ -89,7 +89,10 @@ function createLogFile(logFile, dateFormatted) {
|
|
|
89
89
|
|
|
90
90
|
// Inbox operations
|
|
91
91
|
function parseInboxItems(content) {
|
|
92
|
-
|
|
92
|
+
// \r?\n so a CRLF-line-ending journal (Windows-edited / round-tripped) still
|
|
93
|
+
// parses; without it the section never matched, IDs reset to 1, and a second
|
|
94
|
+
// "## Inbox" section was appended on the next add (duplicate-section corruption).
|
|
95
|
+
const match = content.match(/## Inbox\r?\n([\s\S]*?)(?=\r?\n##|\r?\n---|$)/);
|
|
93
96
|
if (!match) {
|
|
94
97
|
return [];
|
|
95
98
|
}
|
|
@@ -111,7 +114,7 @@ function parseInboxItems(content) {
|
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
function replaceInboxSection(content, items) {
|
|
114
|
-
const regex = /(## Inbox\n)([\s\S]*?)(\n---|\n##|$)/;
|
|
117
|
+
const regex = /(## Inbox\r?\n)([\s\S]*?)(\r?\n---|\r?\n##|$)/;
|
|
115
118
|
if (!regex.test(content)) {
|
|
116
119
|
const lines = items.length ? items.map((item) => item.line).join('\n') : '(Empty - inbox zero achieved)';
|
|
117
120
|
return `${content}\n\n## Inbox\n\n${lines}\n`;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Atris HTML renderer — same content model as the deck engine, rendered to
|
|
2
|
+
// beautiful, anti-slop HTML made of semantic blocks. Pure: spec -> HTML string.
|
|
3
|
+
//
|
|
4
|
+
// Connects with the web apps: the `atris` theme matches atrisos-web tokens
|
|
5
|
+
// (amber #f59e0b on warm dark, --brand-* CSS variables), every section carries
|
|
6
|
+
// data-atris-block="<type>", and renderBlock() emits the AppBlock html shape
|
|
7
|
+
// ({ type:'html', config:{ html } }) so the output drops into an Atris app.
|
|
8
|
+
//
|
|
9
|
+
// Anti-slop by construction: one accent, fluid type, restraint, no gradient
|
|
10
|
+
// text, no glassmorphism, em dashes sanitized (shares slides-deck helpers).
|
|
11
|
+
|
|
12
|
+
const { THEMES: DECK_THEMES, sanitize, parseEmph } = require('./slides-deck');
|
|
13
|
+
|
|
14
|
+
// HTML themes reuse the deck design-system shape, plus `atris` (matches the web app).
|
|
15
|
+
const THEMES = {
|
|
16
|
+
atris: { // warm dark, amber accent, matches atrisos-web (--brand-* tokens, TWKLausanne)
|
|
17
|
+
fonts: { display: 'TWKLausanne', body: 'TWKLausanne', mono: 'ui-monospace, SFMono-Regular, monospace' },
|
|
18
|
+
color: { bg: '#141110', panel: '#1E1915', panelAlt: '#2C2520', line: '#3D332D',
|
|
19
|
+
ink: '#EAE3D9', soft: '#A39B92', faint: '#7C736B',
|
|
20
|
+
accent: '#F59E0B', accent2: '#FBBF24', onAccent: '#141110',
|
|
21
|
+
sev: ['#F59E0B', '#FBBF24', '#7F97A4'] },
|
|
22
|
+
},
|
|
23
|
+
terminal: DECK_THEMES.terminal,
|
|
24
|
+
paper: DECK_THEMES.paper,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function esc(s) {
|
|
28
|
+
return String(s == null ? '' : s)
|
|
29
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// **emphasis** -> <span class="accent">; everything else escaped. Sanitized first.
|
|
33
|
+
function rich(markup) {
|
|
34
|
+
const { plain, ranges } = parseEmph(sanitize(markup));
|
|
35
|
+
if (!ranges.length) return esc(plain);
|
|
36
|
+
let out = '', i = 0;
|
|
37
|
+
for (const r of ranges) {
|
|
38
|
+
out += esc(plain.slice(i, r.start));
|
|
39
|
+
out += `<span class="accent">${esc(plain.slice(r.start, r.end))}</span>`;
|
|
40
|
+
i = r.end;
|
|
41
|
+
}
|
|
42
|
+
out += esc(plain.slice(i));
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function wordmark(brand) {
|
|
47
|
+
const name = esc((brand && brand.name) || 'Atris');
|
|
48
|
+
const ac = (brand && brand.accent) || '';
|
|
49
|
+
return `<div class="mark">${name}${ac ? `<b>${esc(ac)}</b>` : ''}</div>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function panelHtml(p) {
|
|
53
|
+
const rows = (p.rows || []).map((r, i) => `
|
|
54
|
+
<div class="row${r.active ? ' active' : ''}">
|
|
55
|
+
<span class="sev s${(r.sev != null ? r.sev : 0) % 3}"></span>
|
|
56
|
+
<div class="name">${rich(r.title || '')}${r.sub ? `<small>${rich(r.sub)}</small>` : ''}</div>
|
|
57
|
+
${r.value != null ? `<div class="val"><b>${rich(String(r.value))}</b>${r.valueSub ? `<small>${rich(r.valueSub)}</small>` : ''}</div>` : ''}
|
|
58
|
+
</div>`).join('');
|
|
59
|
+
return `<div class="panel">
|
|
60
|
+
${p.header ? `<div class="panel-head"><span>${rich(p.header.title || '')}</span>${p.header.meta ? `<span class="meta">${rich(p.header.meta)}</span>` : ''}</div>` : ''}
|
|
61
|
+
${rows}
|
|
62
|
+
${p.footer ? `<div class="panel-foot"><span>${rich(p.footer.left || '')}</span>${p.footer.right ? `<a>${rich(p.footer.right)}</a>` : ''}</div>` : ''}
|
|
63
|
+
</div>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const BLOCKS = {
|
|
67
|
+
title(s, spec) {
|
|
68
|
+
return `<section class="block hero" data-atris-block="hero">
|
|
69
|
+
<div class="lede">
|
|
70
|
+
${wordmark(spec.brand)}
|
|
71
|
+
<div class="rule"></div>
|
|
72
|
+
<h1>${rich(s.headline || s.title || '')}</h1>
|
|
73
|
+
${s.sub ? `<p class="sub">${rich(s.sub)}</p>` : ''}
|
|
74
|
+
</div>
|
|
75
|
+
${s.panel ? panelHtml(s.panel) : ''}
|
|
76
|
+
</section>`;
|
|
77
|
+
},
|
|
78
|
+
statement(s) {
|
|
79
|
+
return `<section class="block statement" data-atris-block="statement">
|
|
80
|
+
<h2 class="big">${rich(s.text || s.headline || '')}</h2>
|
|
81
|
+
${s.sub ? `<p class="sub">${rich(s.sub)}</p>` : ''}
|
|
82
|
+
</section>`;
|
|
83
|
+
},
|
|
84
|
+
columns(s) {
|
|
85
|
+
const cols = (s.columns || []).map((c) => `
|
|
86
|
+
<div class="col"><h3>${rich(c.h || c.title || '')}</h3>${(c.b || c.body) ? `<p>${rich(c.b || c.body)}</p>` : ''}</div>`).join('');
|
|
87
|
+
return `<section class="block columns" data-atris-block="columns">
|
|
88
|
+
${s.heading ? `<h2 class="heading">${rich(s.heading)}</h2>` : ''}
|
|
89
|
+
<div class="cols c${Math.min((s.columns || []).length, 4)}">${cols}</div>
|
|
90
|
+
</section>`;
|
|
91
|
+
},
|
|
92
|
+
panel(s) {
|
|
93
|
+
return `<section class="block panel-block" data-atris-block="panel">
|
|
94
|
+
<div class="panel-lede">${s.heading ? `<h2>${rich(s.heading)}</h2>` : ''}${s.sub ? `<p>${rich(s.sub)}</p>` : ''}</div>
|
|
95
|
+
${panelHtml(s.panel || { rows: [] })}
|
|
96
|
+
</section>`;
|
|
97
|
+
},
|
|
98
|
+
bignumber(s) {
|
|
99
|
+
return `<section class="block bignumber" data-atris-block="bignumber">
|
|
100
|
+
<div class="num">${rich(String(s.number || s.value || ''))}</div>
|
|
101
|
+
${s.label ? `<div class="num-label">${rich(s.label)}</div>` : ''}
|
|
102
|
+
${s.sub ? `<p class="sub">${rich(s.sub)}</p>` : ''}
|
|
103
|
+
</section>`;
|
|
104
|
+
},
|
|
105
|
+
quote(s) {
|
|
106
|
+
return `<section class="block quote" data-atris-block="quote">
|
|
107
|
+
<blockquote>${rich(s.text || s.quote || '')}</blockquote>
|
|
108
|
+
${s.by ? `<cite>${rich(s.by)}</cite>` : ''}
|
|
109
|
+
</section>`;
|
|
110
|
+
},
|
|
111
|
+
toc(s) {
|
|
112
|
+
const items = (s.items || []).map((it) =>
|
|
113
|
+
`<a class="toc-item" href="${esc(it.href)}"><h3>${rich(it.title || it.href)}</h3>${it.summary ? `<p>${rich(it.summary)}</p>` : ''}</a>`).join('');
|
|
114
|
+
return `<section class="block toc" data-atris-block="toc">
|
|
115
|
+
${s.heading ? `<h2 class="heading">${rich(s.heading)}</h2>` : ''}
|
|
116
|
+
<div class="toc-grid">${items}</div>
|
|
117
|
+
</section>`;
|
|
118
|
+
},
|
|
119
|
+
close(s, spec) {
|
|
120
|
+
const btns = (s.buttons || []).map((b) => `<a class="btn${b.primary ? ' primary' : ''}">${rich(b.label || 'Open')}</a>`).join('');
|
|
121
|
+
return `<section class="block close" data-atris-block="close">
|
|
122
|
+
<div class="rule center"></div>
|
|
123
|
+
${wordmark(spec.brand)}
|
|
124
|
+
${s.tagline ? `<p class="tagline">${rich(s.tagline)}</p>` : ''}
|
|
125
|
+
${btns ? `<div class="btns">${btns}</div>` : ''}
|
|
126
|
+
${s.footer ? `<p class="footer">${rich(s.footer)}</p>` : ''}
|
|
127
|
+
</section>`;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function css(theme) {
|
|
132
|
+
const c = theme.color, f = theme.fonts;
|
|
133
|
+
const gFonts = (f.display === 'Fraunces' || f.body === 'Outfit')
|
|
134
|
+
? `<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=Outfit:wght@300;400;500;600&display=swap" rel="stylesheet">`
|
|
135
|
+
: '';
|
|
136
|
+
const style = `:root{
|
|
137
|
+
--brand-bg:${c.bg};--brand-card:${c.panel};--brand-surface:${c.panelAlt || c.panel};--brand-line:${c.line};
|
|
138
|
+
--brand-text:${c.ink};--brand-text-secondary:${c.soft};--brand-faint:${c.faint};
|
|
139
|
+
--brand-primary:${c.accent};--brand-primary-2:${c.accent2};
|
|
140
|
+
--font-display:${f.display === 'TWKLausanne' ? "'TWKLausanne',system-ui,sans-serif" : `'${f.display}',serif`};
|
|
141
|
+
--font-body:${f.body === 'TWKLausanne' ? "'TWKLausanne',system-ui,sans-serif" : `'${f.body}',sans-serif`};
|
|
142
|
+
--ease:cubic-bezier(.25,1,.5,1);--measure:60ch;
|
|
143
|
+
}
|
|
144
|
+
*{margin:0;box-sizing:border-box}
|
|
145
|
+
html{-webkit-font-smoothing:antialiased}
|
|
146
|
+
body{font-family:var(--font-body);color:var(--brand-text);background:
|
|
147
|
+
radial-gradient(120% 80% at 88% -8%, ${c.accent}1f, transparent 56%),
|
|
148
|
+
radial-gradient(70% 60% at -6% 4%, ${c.soft}14, transparent 52%), var(--brand-bg);
|
|
149
|
+
line-height:1.5;font-variant-numeric:tabular-nums}
|
|
150
|
+
.wrap{max-width:1140px;margin:0 auto;padding:clamp(28px,5vw,72px) clamp(20px,5vw,56px)}
|
|
151
|
+
.block{padding:clamp(40px,7vw,96px) 0;border-bottom:1px solid var(--brand-line)}
|
|
152
|
+
.block:last-child{border-bottom:0}
|
|
153
|
+
.mark{font-family:var(--font-display);font-weight:500;font-size:20px;letter-spacing:-.01em}
|
|
154
|
+
.mark b{color:var(--brand-primary);font-weight:500}
|
|
155
|
+
.rule{width:40px;height:2px;background:var(--brand-primary);margin:22px 0}.rule.center{margin:0 auto 22px}
|
|
156
|
+
.hero{display:grid;grid-template-columns:1.05fr .95fr;gap:clamp(32px,6vw,80px);align-items:center;border-bottom:0;padding-top:clamp(24px,4vw,48px)}
|
|
157
|
+
@media(max-width:860px){.hero{grid-template-columns:1fr}}
|
|
158
|
+
h1{font-family:var(--font-display);font-weight:300;font-size:clamp(2.6rem,6vw,4.6rem);line-height:.99;letter-spacing:-.02em}
|
|
159
|
+
.accent{color:var(--brand-primary-2);font-style:italic}
|
|
160
|
+
.sub{max-width:var(--measure);color:var(--brand-text-secondary);font-size:clamp(1rem,1.4vw,1.15rem);margin-top:22px}
|
|
161
|
+
.big{font-family:var(--font-display);font-weight:300;font-size:clamp(2.2rem,5vw,3.6rem);line-height:1.02;letter-spacing:-.02em}
|
|
162
|
+
.heading{font-family:var(--font-display);font-weight:400;font-size:clamp(1.6rem,3vw,2rem);margin-bottom:34px}
|
|
163
|
+
.cols{display:grid;gap:clamp(20px,4vw,48px)}.cols.c2{grid-template-columns:repeat(2,1fr)}.cols.c3{grid-template-columns:repeat(3,1fr)}.cols.c4{grid-template-columns:repeat(4,1fr)}
|
|
164
|
+
@media(max-width:720px){.cols{grid-template-columns:1fr!important}}
|
|
165
|
+
.col{border-top:1px solid var(--brand-line);padding-top:18px}
|
|
166
|
+
.col h3{font-family:var(--font-display);font-weight:400;font-size:1.25rem;margin-bottom:8px}
|
|
167
|
+
.col p{color:var(--brand-text-secondary);font-size:.97rem;max-width:36ch}
|
|
168
|
+
.panel-block{display:grid;grid-template-columns:.85fr 1.15fr;gap:clamp(24px,5vw,64px);align-items:center}
|
|
169
|
+
@media(max-width:760px){.panel-block{grid-template-columns:1fr}}
|
|
170
|
+
.panel-lede h2{font-family:var(--font-display);font-weight:400;font-size:1.7rem;margin-bottom:14px}
|
|
171
|
+
.panel-lede p{color:var(--brand-text-secondary);max-width:34ch}
|
|
172
|
+
.panel{background:var(--brand-card);border:1px solid var(--brand-line);border-radius:14px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.18)}
|
|
173
|
+
.panel-head{display:flex;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--brand-line);font-size:14px}
|
|
174
|
+
.panel-head .meta{color:var(--brand-faint);font-size:12.5px}
|
|
175
|
+
.row{display:grid;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;padding:14px 18px;border-bottom:1px solid var(--brand-surface)}
|
|
176
|
+
.row:last-child{border-bottom:0}.row.active{border-left:2px solid var(--brand-primary);padding-left:16px}
|
|
177
|
+
.sev{width:8px;height:8px;border-radius:50%}.sev.s0{background:var(--brand-primary)}.sev.s1{background:var(--brand-primary-2)}.sev.s2{background:#7f97a4}
|
|
178
|
+
.name{font-size:14.5px}.name small{display:block;color:var(--brand-faint);font-size:12.5px;margin-top:3px}
|
|
179
|
+
.val{text-align:right;font-size:12.5px;color:var(--brand-faint)}.val b{display:block;color:var(--brand-text);font-size:15px}
|
|
180
|
+
.panel-foot{display:flex;justify-content:space-between;padding:13px 18px;font-size:13px;color:var(--brand-faint)}
|
|
181
|
+
.panel-foot a{color:var(--brand-primary-2);text-decoration:none}
|
|
182
|
+
.bignumber .num{font-family:var(--font-display);font-weight:300;font-size:clamp(3.5rem,11vw,7rem);line-height:1;color:var(--brand-primary-2)}
|
|
183
|
+
.num-label{font-size:1.05rem;margin-top:18px}
|
|
184
|
+
.quote blockquote{font-family:var(--font-display);font-weight:300;font-style:italic;font-size:clamp(1.6rem,3.4vw,2.4rem);line-height:1.25;max-width:24ch;quotes:'\\201C''\\201D'}
|
|
185
|
+
.quote blockquote::before{content:open-quote;color:var(--brand-primary)}.quote blockquote::after{content:close-quote;color:var(--brand-primary)}
|
|
186
|
+
.quote cite{display:block;margin-top:20px;color:var(--brand-text-secondary);font-style:normal;font-size:.95rem}
|
|
187
|
+
.close{text-align:center;border-bottom:0}
|
|
188
|
+
.close .mark{font-family:var(--font-display);font-size:clamp(2.4rem,6vw,3.4rem);font-weight:400}
|
|
189
|
+
.tagline{color:var(--brand-text-secondary);margin-top:10px}
|
|
190
|
+
.btns{display:flex;gap:12px;justify-content:center;margin-top:28px}
|
|
191
|
+
.btn{padding:12px 22px;border-radius:11px;border:1px solid var(--brand-line);color:var(--brand-text);text-decoration:none;font-weight:500;font-size:15px;transition:transform 160ms var(--ease),background-color 160ms var(--ease)}
|
|
192
|
+
.btn:hover{transform:translateY(-1px)}.btn.primary{background:var(--brand-text);color:var(--brand-bg);border-color:var(--brand-text)}
|
|
193
|
+
.footer{margin-top:30px;color:var(--brand-faint);font-size:13px}
|
|
194
|
+
.sitenav{display:flex;justify-content:space-between;align-items:baseline;max-width:1140px;margin:0 auto;padding:20px clamp(20px,5vw,56px);border-bottom:1px solid var(--brand-line)}
|
|
195
|
+
.sitenav .mark{font-size:18px;text-decoration:none;color:var(--brand-text)}
|
|
196
|
+
.sitenav .nav-links{display:flex;gap:24px;font-size:14px;color:var(--brand-text-secondary)}
|
|
197
|
+
.sitenav .nav-links a{color:inherit;text-decoration:none;transition:color 160ms var(--ease)}
|
|
198
|
+
.sitenav .nav-links a:hover{color:var(--brand-text)}
|
|
199
|
+
.toc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:clamp(16px,3vw,32px)}
|
|
200
|
+
.toc-item{display:block;border-top:1px solid var(--brand-line);padding-top:16px;text-decoration:none;color:inherit;transition:border-color 160ms var(--ease)}
|
|
201
|
+
.toc-item:hover{border-color:var(--brand-primary)}
|
|
202
|
+
.toc-item h3{font-family:var(--font-display);font-weight:400;font-size:1.15rem;margin-bottom:6px}
|
|
203
|
+
.toc-item p{color:var(--brand-text-secondary);font-size:.92rem;max-width:40ch}
|
|
204
|
+
@media(prefers-reduced-motion:reduce){*{transition:none!important}}`;
|
|
205
|
+
return { gFonts, style };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderBody(spec, opts = {}) {
|
|
209
|
+
const themes = opts.themes || THEMES;
|
|
210
|
+
const theme = themes[spec.theme] || THEMES.atris;
|
|
211
|
+
const blocks = (spec.slides || spec.blocks || [])
|
|
212
|
+
.map((s) => (BLOCKS[s.type] || BLOCKS.statement)(s, spec))
|
|
213
|
+
.join('\n ');
|
|
214
|
+
return { theme, html: `<div class="wrap">\n ${blocks}\n </div>` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderNav(nav) {
|
|
218
|
+
const links = (nav.links || []).map((l) => `<a href="${esc(l.href)}">${esc(l.label)}</a>`).join('');
|
|
219
|
+
return `<nav class="sitenav"><a class="mark" href="${esc(nav.home || 'index.html')}">${esc(nav.label || 'Atris')}${nav.accent ? `<b>${esc(nav.accent)}</b>` : ''}</a>${links ? `<div class="nav-links">${links}</div>` : ''}</nav>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// full standalone page (opts.nav renders a site header, opts.themes injects brand themes)
|
|
223
|
+
function renderHtml(spec, opts = {}) {
|
|
224
|
+
const { theme, html } = renderBody(spec, opts);
|
|
225
|
+
const { gFonts, style } = css(theme);
|
|
226
|
+
const title = esc(sanitize(opts.title || (spec.brand && spec.brand.name) || 'Atris'));
|
|
227
|
+
return `<!doctype html>
|
|
228
|
+
<html lang="en">
|
|
229
|
+
<head>
|
|
230
|
+
<meta charset="utf-8">
|
|
231
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
232
|
+
<title>${title}</title>
|
|
233
|
+
${gFonts}
|
|
234
|
+
<style>${style}</style>
|
|
235
|
+
</head>
|
|
236
|
+
<body data-atris-theme="${esc(spec.theme || 'atris')}">
|
|
237
|
+
${opts.nav ? renderNav(opts.nav) : ''}
|
|
238
|
+
${html}
|
|
239
|
+
</body>
|
|
240
|
+
</html>`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// AppBlock html shape, ready to embed in an Atris app (ui_template 'html'/'block')
|
|
244
|
+
function renderBlock(spec, opts = {}) {
|
|
245
|
+
return {
|
|
246
|
+
type: 'html',
|
|
247
|
+
title: opts.title || (spec.brand && spec.brand.name) || 'Atris',
|
|
248
|
+
config: {
|
|
249
|
+
block_type: 'html',
|
|
250
|
+
title: opts.title || (spec.brand && spec.brand.name) || 'Atris',
|
|
251
|
+
html: renderHtml(spec, opts),
|
|
252
|
+
theme: spec.theme || 'atris',
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { renderHtml, renderBlock, renderBody, THEMES, BLOCKS, rich, esc };
|
package/lib/journal.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const { spawnSync } = require('child_process');
|
|
6
|
+
const escapeRegExp = require('./escape-regexp');
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Check if two timestamps are effectively the same (within 5ms).
|
|
@@ -235,7 +236,6 @@ function createLogFile(logFile, dateFormatted) {
|
|
|
235
236
|
if (fs.existsSync(prevLogFile)) {
|
|
236
237
|
const prevContent = fs.readFileSync(prevLogFile, 'utf8');
|
|
237
238
|
|
|
238
|
-
const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
239
239
|
const sectionBody = (headingLine) => {
|
|
240
240
|
const regex = new RegExp(
|
|
241
241
|
`## ${escapeRegExp(headingLine)}\\n([\\s\\S]*?)(?=\\n---|\\n## |$)`
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect contradictions in lessons.md and lessons.json.
|
|
6
|
+
*
|
|
7
|
+
* Returns array of contradictions:
|
|
8
|
+
* - P1: same slug, opposite outcome at later date
|
|
9
|
+
* - P2: lesson applies_to file no longer exists, skipping [resolved]/observed/attempted>=3
|
|
10
|
+
*
|
|
11
|
+
* @param {string} cwd - workspace root
|
|
12
|
+
* @returns {Array} array of {type, slug, evidence, remediation}
|
|
13
|
+
*/
|
|
14
|
+
function detectLessonContradictions(cwd) {
|
|
15
|
+
const lessonsMdPath = path.join(cwd, 'atris', 'lessons.md');
|
|
16
|
+
const lessonsJsonPath = path.join(cwd, 'atris', 'lessons.json');
|
|
17
|
+
|
|
18
|
+
const contradictions = [];
|
|
19
|
+
|
|
20
|
+
// Parse lessons.md
|
|
21
|
+
const lessonsMdContent = fs.existsSync(lessonsMdPath)
|
|
22
|
+
? fs.readFileSync(lessonsMdPath, 'utf8')
|
|
23
|
+
: '';
|
|
24
|
+
|
|
25
|
+
// Format: - **[YYYY-MM-DD] slug** — pass|fail — prose
|
|
26
|
+
const lessonRegex = /^-\s+\*\*\[(\d{4}-\d{2}-\d{2})\]\s+([a-z0-9-]+)\*\*\s*—\s*(pass|fail)\s*—\s*(\[resolved\])?\s*(.+)$/m;
|
|
27
|
+
const lines = lessonsMdContent.split('\n');
|
|
28
|
+
|
|
29
|
+
const lessonsBySlug = new Map(); // slug -> [{ date, outcome, lineNum, resolved }]
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
const line = lines[i];
|
|
33
|
+
const match = line.match(lessonRegex);
|
|
34
|
+
if (!match) continue;
|
|
35
|
+
|
|
36
|
+
const [, date, slug, outcome, resolved, prose] = match;
|
|
37
|
+
if (!lessonsBySlug.has(slug)) {
|
|
38
|
+
lessonsBySlug.set(slug, []);
|
|
39
|
+
}
|
|
40
|
+
lessonsBySlug.get(slug).push({
|
|
41
|
+
date,
|
|
42
|
+
outcome,
|
|
43
|
+
lineNum: i + 1,
|
|
44
|
+
resolved: !!resolved,
|
|
45
|
+
prose: prose.trim(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// P1: same slug with opposite outcomes at later dates
|
|
50
|
+
for (const [slug, entries] of lessonsBySlug) {
|
|
51
|
+
// Sort by date
|
|
52
|
+
const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date));
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
55
|
+
const curr = sorted[i];
|
|
56
|
+
const next = sorted[i + 1];
|
|
57
|
+
|
|
58
|
+
// Skip if either is [resolved]
|
|
59
|
+
if (curr.resolved || next.resolved) continue;
|
|
60
|
+
|
|
61
|
+
// Opposite outcomes?
|
|
62
|
+
if (curr.outcome !== next.outcome) {
|
|
63
|
+
contradictions.push({
|
|
64
|
+
type: 'P1_opposite_outcomes',
|
|
65
|
+
slug,
|
|
66
|
+
evidence: `${curr.outcome} on line ${curr.lineNum} (${curr.date}), then ${next.outcome} on later entry (${next.date})`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// P2: applies_to files that no longer exist
|
|
73
|
+
if (fs.existsSync(lessonsJsonPath)) {
|
|
74
|
+
let lessonsJson = {};
|
|
75
|
+
try {
|
|
76
|
+
lessonsJson = JSON.parse(fs.readFileSync(lessonsJsonPath, 'utf8'));
|
|
77
|
+
} catch {
|
|
78
|
+
// invalid JSON, skip
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const [slug, entry] of Object.entries(lessonsJson)) {
|
|
82
|
+
if (!entry.applies_to || !Array.isArray(entry.applies_to)) continue;
|
|
83
|
+
|
|
84
|
+
// Skip if [resolved], observed, or attempted >= 3
|
|
85
|
+
const status = entry.status || '';
|
|
86
|
+
if (status === 'resolved' || status === 'observed') continue;
|
|
87
|
+
|
|
88
|
+
const attempts = entry.attempts || 0;
|
|
89
|
+
if (attempts >= 3) continue;
|
|
90
|
+
|
|
91
|
+
// Check if files exist
|
|
92
|
+
for (const filePath of entry.applies_to) {
|
|
93
|
+
const fullPath = path.isAbsolute(filePath)
|
|
94
|
+
? filePath
|
|
95
|
+
: path.join(cwd, filePath);
|
|
96
|
+
|
|
97
|
+
if (!fs.existsSync(fullPath)) {
|
|
98
|
+
contradictions.push({
|
|
99
|
+
type: 'P2_missing_file',
|
|
100
|
+
slug,
|
|
101
|
+
evidence: `applies_to file no longer exists: ${filePath}`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return contradictions;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
detectLessonContradictions,
|
|
113
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Memory view — another way to look at memory updates. Reads the workspace's
|
|
2
|
+
// compounding memory (lessons.md, learnings.jsonl, task projection) and builds a
|
|
3
|
+
// content spec rendered as a beautiful HTML page by lib/html-render.
|
|
4
|
+
//
|
|
5
|
+
// Pure-ish: file reads in, a { theme, brand, slides } spec out. Anti-slop by
|
|
6
|
+
// construction (the renderer sanitizes, so em dashes in the raw lessons file
|
|
7
|
+
// never reach the page).
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
// "- **[2026-06-18] some-id** — pass — text..." -> { date, id, status, text }
|
|
13
|
+
function parseLessons(text) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const line of String(text || '').split('\n')) {
|
|
16
|
+
const m = line.match(/^-\s*\*\*\[([^\]]+)\]\s*([^*]+?)\*\*\s*[—:-]*\s*(pass|fail)?\s*[—:-]*\s*(.*)$/i);
|
|
17
|
+
if (!m) continue;
|
|
18
|
+
out.push({ date: m[1].trim(), id: m[2].trim(), status: (m[3] || '').toLowerCase(), text: m[4].trim() });
|
|
19
|
+
}
|
|
20
|
+
return out; // append-only file: oldest first
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseLearnings(text) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const line of String(text || '').split('\n')) {
|
|
26
|
+
if (!line.trim()) continue;
|
|
27
|
+
try { const j = JSON.parse(line); out.push({ ts: j.ts, type: j.type, key: j.key, insight: j.insight }); } catch {}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readFileSafe(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } }
|
|
33
|
+
|
|
34
|
+
function readTaskCounts(root) {
|
|
35
|
+
try {
|
|
36
|
+
const j = JSON.parse(readFileSafe(path.join(root, '.atris', 'state', 'tasks.projection.json')));
|
|
37
|
+
const tasks = Array.isArray(j.tasks) ? j.tasks : [];
|
|
38
|
+
const by = (s) => tasks.filter((t) => (t.status || '').toLowerCase() === s).length;
|
|
39
|
+
return { total: tasks.length, done: by('done') + by('accepted'), active: by('active') + by('in_progress'), review: by('review') + by('ready') };
|
|
40
|
+
} catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gatherMemory(root = process.cwd()) {
|
|
44
|
+
const lessons = parseLessons(readFileSafe(path.join(root, 'atris', 'lessons.md')));
|
|
45
|
+
const learnings = parseLearnings(readFileSafe(path.join(root, 'atris', 'learnings.jsonl')));
|
|
46
|
+
const tasks = readTaskCounts(root);
|
|
47
|
+
return { lessons, learnings, tasks };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clip(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; }
|
|
51
|
+
function titleize(id) { return String(id || '').replace(/[-_]+/g, ' ').trim(); }
|
|
52
|
+
function plural(n, word) { return `${n} ${word}${n === 1 ? '' : 's'}`; }
|
|
53
|
+
|
|
54
|
+
function buildMemorySpec(root = process.cwd(), opts = {}) {
|
|
55
|
+
const theme = opts.theme || 'atris';
|
|
56
|
+
const brand = { name: opts.brand || 'Atris', accent: '.' };
|
|
57
|
+
const { lessons, learnings, tasks } = gatherMemory(root);
|
|
58
|
+
const recent = lessons.slice(-8).reverse(); // newest first
|
|
59
|
+
|
|
60
|
+
const slides = [];
|
|
61
|
+
slides.push({
|
|
62
|
+
type: 'title',
|
|
63
|
+
headline: 'What the workspace **learned**',
|
|
64
|
+
sub: `${plural(lessons.length, 'lesson')} and ${plural(learnings.length, 'note')} compounded into memory${tasks ? `, ${plural(tasks.done, 'task')} done` : ''}.`,
|
|
65
|
+
});
|
|
66
|
+
slides.push({ type: 'bignumber', number: String(lessons.length), label: 'lessons compounded into the workspace' });
|
|
67
|
+
|
|
68
|
+
if (recent.length) {
|
|
69
|
+
slides.push({
|
|
70
|
+
type: 'columns', heading: 'Most recent lessons',
|
|
71
|
+
columns: recent.slice(0, 3).map((l) => ({ h: titleize(l.id), b: clip(l.text, 150) })),
|
|
72
|
+
});
|
|
73
|
+
slides.push({
|
|
74
|
+
type: 'panel', heading: 'Memory updates', sub: 'Lessons and notes, newest first.',
|
|
75
|
+
panel: {
|
|
76
|
+
header: { title: 'Recent updates', meta: `${recent.length} shown` },
|
|
77
|
+
rows: recent.map((l, i) => ({
|
|
78
|
+
title: titleize(l.id), sub: l.date,
|
|
79
|
+
value: l.status || 'note', sev: l.status === 'fail' ? 0 : (l.status === 'pass' ? 2 : 1),
|
|
80
|
+
active: i === 0,
|
|
81
|
+
})),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (learnings.length) {
|
|
86
|
+
slides.push({
|
|
87
|
+
type: 'columns', heading: 'Notes from the field',
|
|
88
|
+
columns: learnings.slice(-3).reverse().map((n) => ({ h: titleize(n.key || n.type || 'note'), b: clip(n.insight, 150) })),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
slides.push({ type: 'close', tagline: 'Memory lives in the filesystem, not the model.', footer: `${brand.name} workspace memory` });
|
|
92
|
+
return { theme, brand, slides };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { buildMemorySpec, gatherMemory, parseLessons, parseLearnings, readTaskCounts };
|