ai-growth 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.env.example +19 -0
  2. package/AGENTS.md +232 -0
  3. package/IMPLEMENTATION_PLAN.md +207 -0
  4. package/LICENSE +21 -0
  5. package/PRODUCT_SPEC.md +1776 -0
  6. package/README.md +384 -0
  7. package/SKILL.md +121 -0
  8. package/START-HERE.md +156 -0
  9. package/dist/api/app.js +206 -0
  10. package/dist/api/server.js +42 -0
  11. package/dist/cli/main.js +799 -0
  12. package/dist/core/actions.js +264 -0
  13. package/dist/core/context.js +7 -0
  14. package/dist/core/directions.js +26 -0
  15. package/dist/core/evidence.js +155 -0
  16. package/dist/core/goals.js +188 -0
  17. package/dist/core/lifeContext.js +41 -0
  18. package/dist/core/memory.js +35 -0
  19. package/dist/core/metrics.js +74 -0
  20. package/dist/core/profile.js +95 -0
  21. package/dist/core/progress.js +244 -0
  22. package/dist/core/prompts/index.js +40 -0
  23. package/dist/core/replan.js +100 -0
  24. package/dist/core/rollover.js +68 -0
  25. package/dist/core/rolloverTypes.js +1 -0
  26. package/dist/core/stateMachine.js +61 -0
  27. package/dist/core/status.js +57 -0
  28. package/dist/core/types.js +9 -0
  29. package/dist/daemon/main.js +64 -0
  30. package/dist/db/cli.js +19 -0
  31. package/dist/db/client.js +36 -0
  32. package/dist/db/migrate.js +34 -0
  33. package/dist/db/migrations/V1__init.sql +249 -0
  34. package/dist/device/card.js +257 -0
  35. package/dist/device/deck.js +157 -0
  36. package/dist/device/fontCoverage.js +162 -0
  37. package/dist/device/http.js +170 -0
  38. package/dist/device/png.js +111 -0
  39. package/dist/device/provision.js +185 -0
  40. package/dist/device/render.js +194 -0
  41. package/dist/device/renderCard.js +48 -0
  42. package/dist/device/report.js +146 -0
  43. package/dist/device/serial.js +201 -0
  44. package/dist/device/token.js +78 -0
  45. package/dist/device/types.js +2 -0
  46. package/dist/mcp/ctx.js +48 -0
  47. package/dist/mcp/server.js +15 -0
  48. package/dist/mcp/tools.js +586 -0
  49. package/dist/passport/builder.js +83 -0
  50. package/dist/passport/state.js +81 -0
  51. package/dist/passport/sync.js +61 -0
  52. package/dist/reminder/notify.js +30 -0
  53. package/dist/reminder/scheduler.js +87 -0
  54. package/dist/reminder/service.js +102 -0
  55. package/dist/shared/config.js +50 -0
  56. package/dist/shared/result.js +27 -0
  57. package/dist/shared/time.js +67 -0
  58. package/dist/shared/util.js +21 -0
  59. package/dist/ui/app.js +500 -0
  60. package/dist/ui/index.html +38 -0
  61. package/dist/ui/style.css +367 -0
  62. package/docs/images/cards.png +0 -0
  63. package/docs/images/cover.png +0 -0
  64. package/launchd/com.lairey.ai-growth.daemon.plist +45 -0
  65. package/llms.txt +52 -0
  66. package/package.json +79 -0
  67. package/scripts/check-device-cards.mjs +177 -0
  68. package/scripts/doctor.sh +194 -0
  69. package/scripts/gen-device-assets.mjs +188 -0
  70. package/scripts/install.sh +291 -0
  71. package/scripts/preview-cards.mjs +120 -0
  72. package/scripts/reset-dev-data.sh +92 -0
  73. package/scripts/smoke-device.mjs +401 -0
  74. package/scripts/uninstall.sh +63 -0
  75. package/server.json +56 -0
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 设备卡的「程序化布局断言」:用真实 Chrome 量每个元素的位置,
4
+ * 抓肉眼容易漏的溢出/重叠(完成页统计卡压字、长 meta 顶穿页脚、阶段节点横向溢出)。
5
+ *
6
+ * 为什么必须用真 Chrome 量而不是 jsdom:jsdom 没有排版引擎,
7
+ * `.wrap{bottom}` / `-webkit-line-clamp` 这类都不会生效,量出来恒为 0 —— 等于没测。
8
+ *
9
+ * 用法:node scripts/check-device-cards.mjs [--json]
10
+ * 退出码 1 = 有布局问题。
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url));
19
+ const DIST = join(HERE, '..', 'dist');
20
+
21
+ const { buildCardHtml } = await import(join(DIST, 'device/card.js'));
22
+ const { findChrome, CARD_WIDTH, CARD_HEIGHT } = await import(join(DIST, 'device/render.js'));
23
+ const { buildDeck, dateLabelIn } = await import(join(DIST, 'device/deck.js'));
24
+
25
+ const chrome = findChrome();
26
+ if (!chrome) {
27
+ console.error('找不到 Chrome/Chromium,无法做布局测量。可用 AI_GROWTH_CHROME 指定路径。');
28
+ process.exit(2);
29
+ }
30
+
31
+ // ---- 用例:正常 + 边界(长标题 / 长 meta / 多阶段 / 无电量 / 全完成)----
32
+ const base = {
33
+ dateLabel: dateLabelIn('Asia/Shanghai'),
34
+ dateKey: '2026-09-20',
35
+ stage: { title: 'Memory', order: 2, total: 5 },
36
+ items: [
37
+ { id: 'a1', kind: 'MAIN_QUEST', title: '实现最小 Agent Memory Demo', status: 'PLANNED', done: false, minutes: 60, why: 'Memory 是当前阶段的出口标准', completionCriteria: 'demo 能写入并召回' },
38
+ { id: 'a2', kind: 'DAILY', title: '散步 30 分钟', status: 'PLANNED', done: false, minutes: 30, why: '保持运动习惯' },
39
+ { id: 'a3', kind: 'DAILY', title: '读 20 页书', status: 'PLANNED', done: false, minutes: 20 },
40
+ ],
41
+ doneCount: 0, total: 3, battery: 72,
42
+ facts: { evidence: 37, mainDone: 6 },
43
+ };
44
+ const doneItems = base.items.map((i) => ({ ...i, done: true, status: 'COMPLETED' }));
45
+
46
+ const cases = [
47
+ ['总览', { ...base }, { kind: 'overview', title: 'x' }],
48
+ ['总览·部分完成', { ...base, items: base.items.map((i, n) => ({ ...i, done: n === 0 })), doneCount: 1 }, { kind: 'overview', title: 'x' }],
49
+ ['总览·空条目', { ...base, items: [], total: 0, doneCount: 0 }, { kind: 'overview', title: 'x' }],
50
+ ['总览·无阶段', { ...base, stage: null }, { kind: 'overview', title: 'x' }],
51
+ ['任务·主线', { ...base }, { kind: 'task', itemIndex: 0, title: 'x' }],
52
+ ['任务·日常', { ...base }, { kind: 'task', itemIndex: 1, title: 'x' }],
53
+ ['任务·无附加信息', { ...base, items: [{ id: 'x', kind: 'DAILY', title: '给妈妈打个电话', status: 'PLANNED', done: false }], total: 1, doneCount: 0 }, { kind: 'task', itemIndex: 0, title: 'x' }],
54
+ ['任务·已完成', { ...base, items: [{ ...base.items[0], done: true, status: 'COMPLETED' }], total: 1, doneCount: 1 }, { kind: 'task', itemIndex: 0, title: 'x' }],
55
+ ['完成页', { ...base, items: doneItems, doneCount: 3 }, { kind: 'alldone', title: 'x' }],
56
+ ['完成页·无阶段', { ...base, items: doneItems, doneCount: 3, stage: null }, { kind: 'alldone', title: 'x' }],
57
+ ['无计划', { ...base, items: [], total: 0, doneCount: 0, stage: null }, { kind: 'noplan', title: 'x' }],
58
+ ['离线', { ...base, offlineSince: '9月20日 08:12' }, { kind: 'offline', title: 'x' }],
59
+ ['离线·从未同步', { ...base }, { kind: 'offline', title: 'x' }],
60
+ ['开机', { ...base, battery: null, stage: null }, { kind: 'booting', title: 'x' }],
61
+ ['未配置', { ...base, battery: null, stage: null }, { kind: 'setup', title: 'x' }],
62
+ // ---- 压力 ----
63
+ ['压力·超长标题', { ...base, items: [{ id: 'x', kind: 'MAIN_QUEST', title: '把公司里那套离线优先的同步协议重新设计一遍并写清边界条件', status: 'PLANNED', done: false, minutes: 90, why: '这是当前阶段唯一的出口标准,也是整个系统最脆的部分', completionCriteria: '有一份能被人独立读懂并复现的协议文档,且包含失败路径' }], total: 1, doneCount: 0, battery: -1 }, { kind: 'task', itemIndex: 0, title: 'x' }],
64
+ ['压力·超长阶段名', { ...base, stage: { title: '把 Agent 的记忆系统从零搭起来并跑通端到端', order: 4, total: 5 } }, { kind: 'overview', title: 'x' }],
65
+ ['压力·12 阶段', { ...base, stage: { title: '打磨与交付', order: 11, total: 12 }, battery: 8 }, { kind: 'overview', title: 'x' }],
66
+ ['压力·20 阶段', { ...base, stage: { title: '打磨与交付', order: 3, total: 20 }, battery: 8 }, { kind: 'overview', title: 'x' }],
67
+ ['压力·50 阶段', { ...base, stage: { title: '长跑', order: 40, total: 50 }, battery: 8 }, { kind: 'overview', title: 'x' }],
68
+ ['压力·超长每日标题', { ...base, items: [{ id: 'x', kind: 'DAILY', title: '把今天要交付的那份文档从头到尾再读一遍并逐条核对是否覆盖了所有边界条件', status: 'PLANNED', done: false }], total: 1, doneCount: 0 }, { kind: 'task', itemIndex: 0, title: 'x' }],
69
+ ['压力·3 条长标题', { ...base, items: ['把公司里那套离线优先的同步协议重新设计一遍并写清边界条件', '把今天要交付的那份文档从头到尾再读一遍并逐条核对', '给所有早年的同事逐个写一封说明近况的邮件'].map((t, n) => ({ id: 'x' + n, kind: n === 0 ? 'MAIN_QUEST' : 'DAILY', title: t, status: 'PLANNED', done: false })), total: 3, doneCount: 0, stage: null }, { kind: 'overview', title: 'x' }],
70
+ ];
71
+
72
+ const MEASURE = `
73
+ <script>
74
+ (() => {
75
+ const issues = [];
76
+ const card = document.querySelector('.card');
77
+ const cr = card.getBoundingClientRect();
78
+ const ft = document.querySelector('.ft');
79
+ const fr = ft ? ft.getBoundingClientRect() : null;
80
+ const CONTENT = ['.wrap', '.list', '.ttl', '.meta', '.big', '.done', '.dc', '.facts', '.track', '.stg', '.row', '.hint'];
81
+ for (const sel of CONTENT) {
82
+ for (const el of document.querySelectorAll(sel)) {
83
+ const r = el.getBoundingClientRect();
84
+ if (r.width === 0 && r.height === 0) continue;
85
+ if (r.right > cr.right + 0.5) issues.push(sel + ' 横向溢出 ' + (r.right - cr.right).toFixed(1) + 'px');
86
+ if (r.bottom > cr.bottom + 0.5) issues.push(sel + ' 纵向溢出卡片 ' + (r.bottom - cr.bottom).toFixed(1) + 'px');
87
+ if (fr && !el.closest('.ft') && r.bottom > fr.top + 1) {
88
+ issues.push(sel + ' 压住页脚 ' + (r.bottom - fr.top).toFixed(1) + 'px');
89
+ }
90
+ }
91
+ }
92
+ const nd = document.querySelectorAll('.nd').length;
93
+ if (nd > 12) issues.push('阶段节点 ' + nd + ' 个(>12,轨道会溢出)');
94
+ const pre = document.createElement('pre');
95
+ pre.id = '__report';
96
+ pre.textContent = JSON.stringify(issues);
97
+ document.body.appendChild(pre);
98
+ })();
99
+ </script>`;
100
+
101
+ /** 用 Chrome --dump-dom 跑页面内的测量脚本,把结果读回来 */
102
+ function measure(html) {
103
+ const dir = mkdtempSync(join(tmpdir(), 'aig-layout-'));
104
+ const file = join(dir, 'card.html');
105
+ writeFileSync(file, html.replace('</body>', MEASURE + '</body>'), 'utf8');
106
+ return new Promise((resolve) => {
107
+ const child = spawn(chrome, [
108
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--no-first-run',
109
+ '--no-default-browser-check', '--disable-extensions',
110
+ `--user-data-dir=${join(dir, 'profile')}`,
111
+ '--virtual-time-budget=1200', '--dump-dom', `file://${file}`,
112
+ ], { stdio: ['ignore', 'pipe', 'ignore'], detached: true });
113
+ let out = '';
114
+ child.stdout.on('data', (d) => { out += String(d); });
115
+ const finish = () => {
116
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* 已退出 */ }
117
+ try { child.kill('SIGKILL'); } catch { /* 已退出 */ }
118
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
119
+ const m = out.match(/<pre id="__report">([\s\S]*?)<\/pre>/);
120
+ resolve(m ? JSON.parse(m[1]) : null);
121
+ };
122
+ child.on('close', finish);
123
+ child.on('error', finish);
124
+ setTimeout(finish, 15000);
125
+ });
126
+ }
127
+
128
+ const asJson = process.argv.includes('--json');
129
+ let failed = 0;
130
+ const rows = [];
131
+
132
+ for (const [name, state, spec] of cases) {
133
+ const res = await measure(buildCardHtml(state, spec));
134
+ if (!res) {
135
+ rows.push({ name, ok: false, issues: ['测量失败(Chrome 没回吐报告)'] });
136
+ failed++;
137
+ continue;
138
+ }
139
+ rows.push({ name, ok: res.length === 0, issues: res });
140
+ if (res.length) failed++;
141
+ }
142
+
143
+ // 牌组组成也要核一遍:没任务 → 只一张 noplan;全完成 → 末尾补 alldone
144
+ const deckChecks = [];
145
+ deckChecks.push(['空计划 → noplan', JSON.stringify(buildDeck({ ...base, items: [], total: 0, doneCount: 0 }).map((c) => c.kind)) === '["noplan"]']);
146
+ deckChecks.push(['有任务 → overview 打头', buildDeck(base)[0].kind === 'overview']);
147
+ deckChecks.push(['全完成 → 末位 alldone', buildDeck({ ...base, items: doneItems, doneCount: 3 }).at(-1).kind === 'alldone']);
148
+ deckChecks.push(['未全完成 → 无 alldone', !buildDeck({ ...base, doneCount: 1 }).some((c) => c.kind === 'alldone')]);
149
+
150
+ // 自检:往一张本来合格的卡里塞一个必然越界的元素,测量脚本必须报出来。
151
+ // 没有这一步的话,一个恒为绿的检查等于没检查。
152
+ const poison = buildCardHtml(base, { kind: 'overview', title: 'x' }).replace(
153
+ '</body>',
154
+ '<div class="wrap" style="position:absolute;top:282px;left:0;width:400px;height:80px"></div></body>'
155
+ );
156
+ const poisonRes = await measure(poison);
157
+ const selfTest = Array.isArray(poisonRes) && poisonRes.length > 0;
158
+
159
+ if (asJson) {
160
+ console.log(JSON.stringify({ failed, rows, deckChecks, selfTest }, null, 2));
161
+ } else {
162
+ console.log(`设备卡布局测量(${cases.length} 张,真实 Chrome ${CARD_WIDTH}×${CARD_HEIGHT})\n`);
163
+ for (const r of rows) {
164
+ console.log(`${r.ok ? '✓' : '✗'} ${r.name}`);
165
+ for (const i of r.issues) console.log(` · ${i}`);
166
+ }
167
+ console.log('\n牌组组成:');
168
+ for (const [label, ok] of deckChecks) {
169
+ if (!ok) failed++;
170
+ console.log(`${ok ? '✓' : '✗'} ${label}`);
171
+ }
172
+ console.log(`\n自检(故意塞越界元素,必须被报出):${selfTest ? '✓ 检查有效' : '✗ 检查失效 —— 上面的绿色不可信'}`);
173
+ if (!selfTest) failed++;
174
+ console.log(failed ? `\n✗ 共 ${failed} 处问题` : `\n✓ 全部通过`);
175
+ }
176
+
177
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env bash
2
+ # AI Growth — 健康检查(doctor)
3
+ # 逐项验证:Node / 依赖 / 构建 / 数据库 / launchd / MCP / Web UI / 数据可写 / 时区
4
+ # 退出码:0 全部通过;1 存在失败项
5
+
6
+ set -uo pipefail
7
+
8
+ PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ DATA_DIR="${AI_GROWTH_DATA_DIR:-$HOME/.ai-growth}"
10
+ PLIST_LABEL="com.lairey.ai-growth.daemon"
11
+ PLIST_DEST="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist"
12
+ NODE_BIN="$(command -v node || true)"
13
+
14
+ PASS=0; FAIL=0; INFO=0
15
+ ok() { printf " \033[0;32m✓\033[0m %s\n" "$1"; PASS=$((PASS+1)); }
16
+ bad() { printf " \033[0;31m✗\033[0m %s\n" "$1"; FAIL=$((FAIL+1)); }
17
+ info() { printf " \033[0;33m-\033[0m %s\n" "$1"; INFO=$((INFO+1)); }
18
+ head_() { printf "\n\033[1m%s\033[0m\n" "$1"; }
19
+
20
+ printf "\033[1mAI Growth Doctor\033[0m\n项目:%s\n数据:%s\n" "$PROJECT_DIR" "$DATA_DIR"
21
+
22
+ # 1. Node
23
+ head_ "运行环境"
24
+ if [ -n "$NODE_BIN" ]; then
25
+ V="$("$NODE_BIN" -p 'process.versions.node')"
26
+ MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]')"
27
+ if [ "$MAJOR" -ge 20 ]; then ok "node v$V"; else bad "node v$V 版本过低(需要 >= 20)"; fi
28
+ else
29
+ bad "未找到 node"
30
+ fi
31
+
32
+ # 2. 依赖
33
+ head_ "依赖"
34
+ if [ -d "$PROJECT_DIR/node_modules/better-sqlite3" ]; then ok "better-sqlite3 已安装"; else bad "better-sqlite3 缺失(运行 npm install)"; fi
35
+ if [ -d "$PROJECT_DIR/node_modules/@modelcontextprotocol/sdk" ]; then ok "MCP SDK 已安装"; else bad "MCP SDK 缺失(运行 npm install)"; fi
36
+ if [ -d "$PROJECT_DIR/node_modules/zod" ]; then ok "zod 已安装"; else bad "zod 缺失(运行 npm install)"; fi
37
+
38
+ # 3. 构建产物
39
+ head_ "构建"
40
+ for f in dist/mcp/server.js dist/daemon/main.js dist/api/server.js dist/db/cli.js dist/db/migrations/V1__init.sql dist/ui/index.html; do
41
+ if [ -f "$PROJECT_DIR/$f" ]; then ok "$f"; else bad "$f 缺失(运行 npm run build)"; fi
42
+ done
43
+
44
+ # 3b. 构建产物是否与源码同步(曾出现 cp -R 套娃导致 dist 静默陈旧)
45
+ STALE=0
46
+ for pair in "src/db/migrations/V1__init.sql:dist/db/migrations/V1__init.sql" \
47
+ "src/ui/index.html:dist/ui/index.html" \
48
+ "src/ui/app.js:dist/ui/app.js" \
49
+ "src/ui/style.css:dist/ui/style.css"; do
50
+ S="${pair%%:*}"; D="${pair##*:}"
51
+ if [ -f "$PROJECT_DIR/$S" ] && [ -f "$PROJECT_DIR/$D" ]; then
52
+ cmp -s "$PROJECT_DIR/$S" "$PROJECT_DIR/$D" || { bad "构建产物陈旧:$D ≠ ${S}(运行 npm run build)"; STALE=1; }
53
+ fi
54
+ done
55
+ [ "$STALE" -eq 0 ] && ok "dist 与 src 一致(migration / UI 均已同步)"
56
+
57
+ # 3c. 套娃目录(cp -R 到已存在目录会产生 dist/ui/ui)
58
+ NESTED="$(find "$PROJECT_DIR/dist" -maxdepth 3 -type d -name ui -o -maxdepth 3 -type d -name migrations 2>/dev/null | sed -n '2,$p' | grep -E "/(ui/ui|migrations/migrations)$" || true)"
59
+ if [ -n "$NESTED" ]; then
60
+ bad "存在套娃目录:$(echo "$NESTED" | tr '\n' ' ')(npm run clean && npm run build)"
61
+ else
62
+ ok "无套娃目录"
63
+ fi
64
+
65
+ # 4. 数据库
66
+ head_ "数据库"
67
+ if [ -f "$DATA_DIR/growth.db" ]; then
68
+ ok "growth.db 存在"
69
+ if [ -n "$NODE_BIN" ]; then
70
+ APPLIED="$("$NODE_BIN" -e "
71
+ const D=require('$PROJECT_DIR/node_modules/better-sqlite3');
72
+ const db=new D('$DATA_DIR/growth.db',{readonly:true});
73
+ try{
74
+ const r=db.prepare('SELECT COUNT(*) n FROM _migrations').get();
75
+ const t=db.prepare(\"SELECT COUNT(*) n FROM sqlite_master WHERE type='table'\").get();
76
+ console.log(r.n+'|'+t.n);
77
+ }catch(e){console.log('ERR');}
78
+ " 2>/dev/null)"
79
+ if [ "$APPLIED" = "ERR" ]; then bad "无法读取 schema(数据库可能损坏)"; else
80
+ M="${APPLIED%%|*}"; T="${APPLIED##*|}"
81
+ ok "migration 已应用:$M 个;表数量:$T"
82
+ fi
83
+ fi
84
+ else
85
+ bad "growth.db 不存在(运行 node dist/db/cli.js migrate)"
86
+ fi
87
+
88
+ # 5. 数据目录可写
89
+ head_ "数据目录"
90
+ if [ -d "$DATA_DIR" ]; then
91
+ if [ -w "$DATA_DIR" ]; then ok "$DATA_DIR 可写"; else bad "$DATA_DIR 不可写"; fi
92
+ else
93
+ bad "$DATA_DIR 不存在"
94
+ fi
95
+
96
+ # 6. 时区
97
+ head_ "时区"
98
+ if [ -n "$NODE_BIN" ]; then
99
+ TZ_="$(AI_GROWTH_DATA_DIR="$DATA_DIR" "$NODE_BIN" -e "
100
+ process.env.AI_GROWTH_TZ=process.env.AI_GROWTH_TZ||Intl.DateTimeFormat().resolvedOptions().timeZone;
101
+ console.log(process.env.AI_GROWTH_TZ);
102
+ " 2>/dev/null)"
103
+ if [ -n "$TZ_" ]; then ok "解析为 $TZ_"; else bad "无法解析时区"; fi
104
+ fi
105
+
106
+ # 7. launchd
107
+ head_ "常驻服务(launchd)"
108
+ UID_NUM="$(id -u)"
109
+ if [ "$(uname -s)" != "Darwin" ]; then
110
+ info "非 macOS,跳过(Linux 需改为 systemd unit)"
111
+ elif [ ! -f "$PLIST_DEST" ]; then
112
+ bad "plist 未安装(运行 ./scripts/install.sh)"
113
+ else
114
+ ok "plist 已安装:$PLIST_DEST"
115
+ if ! launchctl list >/dev/null 2>&1; then
116
+ info "当前进程无权查询 launchd(正常,非故障)——plist 会在下次登录时自动加载"
117
+ printf " 要立刻生效,在「终端」执行:launchctl bootstrap gui/%s \"%s\"\n" "$UID_NUM" "$PLIST_DEST"
118
+ printf " 验证是否在跑:launchctl list | grep %s\n" "$PLIST_LABEL"
119
+ elif launchctl list 2>/dev/null | grep -q "$PLIST_LABEL"; then
120
+ LINE="$(launchctl list | grep "$PLIST_LABEL" | head -1)"
121
+ PID="$(echo "$LINE" | awk '{print $1}')"
122
+ if [ "$PID" = "-" ]; then
123
+ bad "daemon 未在运行(已加载但进程退出,查看 $DATA_DIR/daemon-stderr.log)"
124
+ else
125
+ ok "daemon 运行中(pid ${PID})"
126
+ fi
127
+ else
128
+ info "服务尚未加载——下次登录会自动加载;想立刻生效在「终端」执行:"
129
+ printf " launchctl bootstrap gui/%s \"%s\"\n" "$UID_NUM" "$PLIST_DEST"
130
+ fi
131
+ fi
132
+
133
+ # 8. MCP server 可启动
134
+ head_ "MCP Server"
135
+ if [ -f "$PROJECT_DIR/dist/mcp/server.js" ] && [ -n "$NODE_BIN" ]; then
136
+ TMP_OUT="$(mktemp -t aigrowth-mcp)"
137
+ # stdio MCP server 在 stdin EOF 时会正常退出,故用 sleep 管道保持 stdin 打开。
138
+ # 注意:直接在主 shell 取 $!,不要用子 shell + PID 文件(存在竞态)。
139
+ { sleep 5; } | AI_GROWTH_DATA_DIR="$DATA_DIR" "$NODE_BIN" "$PROJECT_DIR/dist/mcp/server.js" >"$TMP_OUT" 2>&1 &
140
+ MCP_PID=$!
141
+ sleep 3
142
+
143
+ ALIVE=0
144
+ kill -0 "$MCP_PID" 2>/dev/null && ALIVE=1
145
+
146
+ if grep -q "MCP server ready" "$TMP_OUT" 2>/dev/null; then
147
+ ok "启动成功:DB 打开 + migration + tools 注册完成"
148
+ if [ "$ALIVE" -eq 1 ]; then ok "进程保持运行(pid ${MCP_PID})"; fi
149
+ elif [ "$ALIVE" -eq 1 ]; then
150
+ ok "进程运行中(pid ${MCP_PID}),未捕获就绪日志"
151
+ else
152
+ bad "MCP server 启动失败:$(head -3 "$TMP_OUT" 2>/dev/null | tr '\n' ' ')"
153
+ fi
154
+
155
+ kill "$MCP_PID" 2>/dev/null || true
156
+ pkill -P "$MCP_PID" 2>/dev/null || true
157
+ rm -f "$TMP_OUT"
158
+ else
159
+ bad "dist/mcp/server.js 缺失"
160
+ fi
161
+
162
+ # 9. 面板 API / Web UI
163
+ head_ "面板 API"
164
+ PORT="${AI_GROWTH_API_PORT:-4580}"
165
+ # 用 node 直连 127.0.0.1,不要用 curl:受限环境的 HTTP 代理会把本地请求变成 502,误判为"未运行"
166
+ PANEL_OK=0
167
+ if [ -n "$NODE_BIN" ]; then
168
+ PANEL_OUT="$(AI_GROWTH_PROBE_PORT="$PORT" "$NODE_BIN" -e "
169
+ const http=require('http');
170
+ const req=http.get({host:'127.0.0.1',port:process.env.AI_GROWTH_PROBE_PORT,path:'/api/health',timeout:2500},res=>{
171
+ let d='';res.on('data',c=>d+=c);res.on('end',()=>{console.log(res.statusCode+'|'+d.replace(/\s+/g,''));process.exit(0)});
172
+ });
173
+ req.on('timeout',()=>{console.log('TIMEOUT');process.exit(0)});
174
+ req.on('error',e=>{console.log('ERR:'+e.code);process.exit(0)});
175
+ " 2>/dev/null)"
176
+ case "$PANEL_OUT" in
177
+ 200*) PANEL_OK=1 ;;
178
+ esac
179
+ fi
180
+ if [ "$PANEL_OK" -eq 1 ]; then
181
+ ok "面板可访问:http://127.0.0.1:${PORT}(由 daemon 或 npm run api 提供)"
182
+ else
183
+ info "面板未运行(daemon 会自带面板;或手动:npm run api)"
184
+ fi
185
+
186
+ # 汇总
187
+ printf "\n\033[1m结果:%d 项通过,%d 项失败" "$PASS" "$FAIL"
188
+ [ "$INFO" -gt 0 ] && printf ",%d 项提示" "$INFO"
189
+ printf "\033[0m\n"
190
+ if [ "$FAIL" -gt 0 ]; then
191
+ printf "\033[0;31m存在失败项,请按上方提示修复。\033[0m\n"
192
+ exit 1
193
+ fi
194
+ printf "\033[0;32m核心检查全部通过。\033[0m\n"
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 生成 AI Passport 固件内置的「兜底卡」资源,并顺带校验中文字形覆盖。
4
+ *
5
+ * 为什么兜底卡要在【构建期】出图:
6
+ * 设备的开机卡、离线卡、未配置卡必须在不联网、没配置的情况下也能显示中文。
7
+ * 而固件里没有可用的中文字库(见 docs/development/engineering/lvgl-chinese-fonts.md)。
8
+ * 所以这三张图在构建期就用与运行时完全相同的 HTML/CSS 渲染成 RGB565 塞进 Flash,
9
+ * 设备端一个汉字都不用画 —— 中文显示问题在宿主侧一次性解决。
10
+ *
11
+ * 用法:
12
+ * npm run build && node scripts/gen-device-assets.mjs \
13
+ * --out <ai-passport>/main/assets/growth \
14
+ * --preview <ai-passport>/assets/growth/firmware
15
+ *
16
+ * 产物(与仓库里 rock-paper-scissors 示例的布局一致:
17
+ * 只有二进制进 main/assets/,给人看的预览和说明进 assets/):
18
+ * <out>/booting.rgb565 offline.rgb565 setup.rgb565 240×320 RGB565 小端,各 153600 字节
19
+ * <out>/manifest.json 尺寸/哈希/字形覆盖结论
20
+ * <preview>/<name>.png 预览图(不参与固件)
21
+ *
22
+ * 退出码:0 = 资源生成且字形覆盖检查通过;非 0 = 生成失败或发现缺字(不会留下"看起来成功"的产物)。
23
+ */
24
+ import { createHash } from 'node:crypto';
25
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
26
+ import { dirname, join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ const root = resolve(here, '..');
31
+
32
+ function argValue(flag, fallback) {
33
+ const i = process.argv.indexOf(flag);
34
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
35
+ }
36
+
37
+ const outDir = resolve(argValue('--out', join(root, 'out', 'device-assets')));
38
+ const previewDir = resolve(argValue('--preview', join(outDir, 'preview')));
39
+ const distDir = join(root, 'dist', 'device');
40
+
41
+ if (!existsSync(join(distDir, 'card.js'))) {
42
+ console.error('未找到 dist/device/card.js —— 先跑 `npm run build`。');
43
+ process.exit(2);
44
+ }
45
+
46
+ const { buildCardHtml } = await import(join(distDir, 'card.js'));
47
+ const { screenshotPng, toRgb565, CARD_WIDTH, CARD_HEIGHT } = await import(join(distDir, 'render.js'));
48
+ const { decodePng } = await import(join(distDir, 'png.js'));
49
+ const { renderGlyphMasks, classifyCoverage } = await import(join(distDir, 'fontCoverage.js'));
50
+ const { encodeCardPayload, buildCardMeta, CARD_W, CARD_H } = await import(join(distDir, 'deck.js'));
51
+
52
+ /** 与 check-device-cards.mjs 保持同一套"最坏情况"素材,避免两边覆盖不一致 */
53
+ const BASE_ITEMS = [
54
+ { id: 'a1', kind: 'MAIN_QUEST', title: '实现最小 Agent Memory Demo', status: 'PLANNED', done: false, minutes: 60, why: 'Memory 是当前阶段的出口标准', completionCriteria: 'demo 能写入并召回' },
55
+ { id: 'a2', kind: 'DAILY', title: '散步 30 分钟', status: 'PLANNED', done: false, minutes: 30, why: '保持运动习惯' },
56
+ { id: 'a3', kind: 'DAILY', title: '读 20 页书', status: 'PLANNED', done: false, minutes: 20 },
57
+ ];
58
+ const BASE = {
59
+ dateLabel: '9月20日 周日',
60
+ dateKey: '2026-09-20',
61
+ stage: { title: '跑通端到端最小闭环', order: 3, total: 6 },
62
+ items: BASE_ITEMS,
63
+ doneCount: 0,
64
+ total: BASE_ITEMS.length,
65
+ battery: 76,
66
+ facts: { evidence: 14, mainDone: 3 },
67
+ };
68
+
69
+ /**
70
+ * 要写进 Flash 的兜底卡(必须成功)。
71
+ * ⚠ dateLabel 一律留空:这三张卡是构建期渲染的,设备上没有 RTC;
72
+ * 把构建日的日期烧进去 = 设备天天说"今天是 9 月 20 日"。时间只能由服务端给。
73
+ * 后面那几张"覆盖用"的卡只用来把字符集铺开,不产出资源。
74
+ */
75
+ const FALLBACK = [
76
+ ['booting', { ...BASE, dateLabel: '', dateKey: '', items: [], total: 0, doneCount: 0, battery: null, stage: null }, { kind: 'booting', title: '正在连接' }],
77
+ ['offline', { ...BASE, dateLabel: '', dateKey: '', items: [], total: 0, doneCount: 0, battery: null, stage: null }, { kind: 'offline', title: '连不上电脑' }],
78
+ ['setup', { ...BASE, dateLabel: '', dateKey: '', items: [], total: 0, doneCount: 0, battery: null, stage: null }, { kind: 'setup', title: '还没配置' }],
79
+ ];
80
+
81
+ const COVERAGE_ONLY = [
82
+ ['总览', { ...BASE }, { kind: 'overview', title: 'x' }],
83
+ ['总览·12 阶段', { ...BASE, stage: { title: '打磨与交付', order: 11, total: 12 } }, { kind: 'overview', title: 'x' }],
84
+ ['任务·主线', { ...BASE }, { kind: 'task', itemIndex: 0, title: 'x' }],
85
+ ['任务·无附加信息', { ...BASE, items: [{ id: 'x', kind: 'DAILY', title: '给妈妈打个电话', status: 'PLANNED', done: false }], total: 1, doneCount: 0 }, { kind: 'task', itemIndex: 0, title: 'x' }],
86
+ ['完成页', { ...BASE, items: BASE_ITEMS.map((i) => ({ ...i, done: true })), doneCount: 3 }, { kind: 'alldone', title: 'x' }],
87
+ ['无计划', { ...BASE, items: [], total: 0, doneCount: 0, stage: null }, { kind: 'noplan', title: 'x' }],
88
+ ['压力·超长标题', { ...BASE, items: [{ id: 'x', kind: 'MAIN_QUEST', title: '把公司里那套离线优先的同步协议重新设计一遍并写清边界条件', status: 'PLANNED', done: false, minutes: 90, why: '这是当前阶段唯一的出口标准,也是整个系统最脆的部分', completionCriteria: '有一份能被人独立读懂并复现的协议文档,且包含失败路径' }], total: 1, doneCount: 0, battery: -1 }, { kind: 'task', itemIndex: 0, title: 'x' }],
89
+ ['压力·下标数字', { ...BASE, stage: { title: '长跑', order: 40, total: 50 }, battery: 8 }, { kind: 'overview', title: 'x' }],
90
+ ['标点与符号', { ...BASE, items: [{ id: 'x', kind: 'DAILY', title: '写《周报》初稿(第 3 版);含 13% 数据、A/B 对比', status: 'PLANNED', done: false, why: '用全角标点与括号测字形:,。;:!?()《》、' }], total: 1, doneCount: 0 }, { kind: 'task', itemIndex: 0, title: 'x' }],
91
+ ];
92
+
93
+ /** 从卡片 HTML 里抽出"真正会画出来的文字",交给字形覆盖检查 */
94
+ function visibleText(html) {
95
+ return html
96
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
97
+ .replace(/<[^>]*>/g, ' ')
98
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
99
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
100
+ .replace(/&nbsp;/g, ' ');
101
+ }
102
+
103
+ async function renderOne(state, spec) {
104
+ const html = buildCardHtml(state, spec);
105
+ const png = await screenshotPng(html, { width: CARD_WIDTH, height: CARD_HEIGHT });
106
+ const img = decodePng(png);
107
+ if (img.width !== CARD_WIDTH || img.height !== CARD_HEIGHT) {
108
+ throw new Error(`${spec.kind}: 渲染出 ${img.width}x${img.height},期望 ${CARD_WIDTH}x${CARD_HEIGHT}`);
109
+ }
110
+ return { html, png, pixels: toRgb565(img) };
111
+ }
112
+
113
+ async function main() {
114
+ mkdirSync(outDir, { recursive: true });
115
+ mkdirSync(previewDir, { recursive: true });
116
+
117
+ const manifest = {
118
+ card: { width: CARD_W, height: CARD_H, pixelFormat: 'RGB565 little-endian' },
119
+ generatedFrom: 'scripts/gen-device-assets.mjs',
120
+ assets: [],
121
+ coverage: null,
122
+ };
123
+
124
+ // 1) 生成兜底卡(先删旧的,避免"生成失败但旧文件还在"的假成功)
125
+ for (const [name] of FALLBACK) {
126
+ const f = join(outDir, `${name}.rgb565`);
127
+ if (existsSync(f)) rmSync(f, { force: true });
128
+ }
129
+
130
+ const texts = [];
131
+ for (const [name, state, spec] of FALLBACK) {
132
+ const { html, png, pixels } = await renderOne(state, spec);
133
+ if (pixels.length !== CARD_W * CARD_H * 2) {
134
+ throw new Error(`${name}: RGB565 长度 ${pixels.length},期望 ${CARD_W * CARD_H * 2}`);
135
+ }
136
+ // 自检:用与运行时相同的编码器包一层,再解回来,确保这 153600 字节能被固件原样解析
137
+ const meta = buildCardMeta(state, spec, 0, 1);
138
+ const payload = encodeCardPayload(meta, pixels);
139
+ if (payload.length !== 14 + Buffer.byteLength(JSON.stringify(meta), 'utf8') + pixels.length) {
140
+ throw new Error(`${name}: 载荷长度自检失败`);
141
+ }
142
+ writeFileSync(join(outDir, `${name}.rgb565`), pixels);
143
+ writeFileSync(join(previewDir, `${name}.png`), png);
144
+ texts.push(visibleText(html));
145
+ manifest.assets.push({
146
+ name,
147
+ file: `${name}.rgb565`,
148
+ bytes: pixels.length,
149
+ sha256: createHash('sha256').update(pixels).digest('hex').slice(0, 16),
150
+ payloadBytes: payload.length,
151
+ });
152
+ console.log(`✓ ${name}.rgb565 ${pixels.length} 字节`);
153
+ }
154
+
155
+ // 2) 把字符集铺开(只渲染不落盘,用于覆盖检查)
156
+ for (const [name, state, spec] of COVERAGE_ONLY) {
157
+ const html = buildCardHtml(state, spec);
158
+ texts.push(visibleText(html));
159
+ void name;
160
+ }
161
+
162
+ // 3) 字形覆盖:让同一套 Chrome 把每个字符画进网格,再与"缺字参考格"逐像素比对
163
+ const allText = texts.join('');
164
+ const masks = await renderGlyphMasks(allText);
165
+ const coverage = classifyCoverage(masks);
166
+ manifest.coverage = {
167
+ ok: coverage.ok,
168
+ detectorOk: coverage.detectorOk,
169
+ checked: coverage.checked,
170
+ missing: coverage.missing,
171
+ noInk: coverage.noInk,
172
+ message: coverage.message,
173
+ };
174
+ console.log(`${coverage.ok ? '✓' : '✗'} ${coverage.message}`);
175
+
176
+ writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
177
+
178
+ if (!coverage.ok) {
179
+ console.error('字形覆盖检查未通过 —— 不交付这些资源。');
180
+ process.exit(1);
181
+ }
182
+
183
+ const total = manifest.assets.reduce((n, a) => n + a.bytes, 0);
184
+ console.log(`\n资源目录 ${outDir}`);
185
+ console.log(`共 ${manifest.assets.length} 张,合计 ${total} 字节(约 ${(total / 1024).toFixed(0)} KB Flash)`);
186
+ }
187
+
188
+ await main();