total-recall-brain 3.28.2 → 3.30.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 (132) hide show
  1. package/bin/total-recall.mjs +24 -1
  2. package/docs/ARCHITECTURE.md +26 -18
  3. package/extension/background.js +259 -84
  4. package/extension/content-script.css +7 -13
  5. package/extension/content-script.js +252 -477
  6. package/extension/icons/icon-128.png +0 -0
  7. package/extension/icons/icon-16.png +0 -0
  8. package/extension/icons/icon-32.png +0 -0
  9. package/extension/icons/icon-48.png +0 -0
  10. package/extension/icons/mark.svg +6 -0
  11. package/extension/lib/brain-client.js +160 -73
  12. package/extension/lib/tokens.css +187 -0
  13. package/extension/lib/ui-helpers.js +229 -0
  14. package/extension/lib/ui-helpers.spec.mjs +94 -0
  15. package/extension/manifest.json +20 -5
  16. package/extension/options/options.css +64 -218
  17. package/extension/options/options.html +91 -43
  18. package/extension/options/options.js +208 -85
  19. package/extension/sidepanel/sidepanel.css +268 -970
  20. package/extension/sidepanel/sidepanel.html +200 -166
  21. package/extension/sidepanel/sidepanel.js +788 -942
  22. package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
  23. package/frontend/dist/index.html +1 -1
  24. package/frontend/src/api/plugins.ts +124 -92
  25. package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
  26. package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
  27. package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
  28. package/frontend/src/pages/PluginsPage.tsx +454 -1218
  29. package/package.json +2 -1
  30. package/plugins/code-quality/README.md +25 -0
  31. package/plugins/code-quality/cli.mjs +25 -0
  32. package/plugins/code-quality/plugin.json +17 -0
  33. package/plugins/git-sentinel/README.md +27 -0
  34. package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
  35. package/plugins/git-sentinel/plugin.json +18 -0
  36. package/plugins/system-monitor/README.md +26 -0
  37. package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
  38. package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
  39. package/plugins/system-monitor/plugin.json +29 -0
  40. package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
  41. package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
  42. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  43. package/src/cli/agent.mjs +1 -1
  44. package/src/cli/app/index.mjs +493 -0
  45. package/src/cli/brain.mjs +33 -3
  46. package/src/cli/command.mjs +80 -41
  47. package/src/cli/harness.mjs +1 -1
  48. package/src/cli/init.mjs +3 -1
  49. package/src/cli/mesh.mjs +118 -12
  50. package/src/cli/plugin/create.mjs +144 -25
  51. package/src/cli/plugin/index.mjs +83 -53
  52. package/src/cli/plugin/install.mjs +10 -127
  53. package/src/cli/plugin/list.mjs +44 -44
  54. package/src/cli/plugin/remove.mjs +23 -30
  55. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  56. package/src/cli/plugin/search.mjs +108 -23
  57. package/src/cli/rebuild.mjs +37 -1
  58. package/src/cli/recall.mjs +17 -2
  59. package/src/cli/research.mjs +4 -6
  60. package/src/cli/share.mjs +2 -0
  61. package/src/core/app-deploy/apply.mjs +386 -0
  62. package/src/core/app-deploy/design-tokens.mjs +217 -0
  63. package/src/core/app-deploy/plan.mjs +249 -0
  64. package/src/core/app-deploy/resolve.mjs +295 -0
  65. package/src/core/app-deploy/source.mjs +337 -0
  66. package/src/core/app-deploy/standalone.mjs +248 -0
  67. package/src/core/app-deploy/upgrade.mjs +256 -0
  68. package/src/core/app-deploy/verify.mjs +213 -0
  69. package/src/core/bound-hosts.mjs +1 -1
  70. package/src/core/brain-registry.mjs +146 -0
  71. package/src/core/brain-state.json +1 -0
  72. package/src/core/daemon-loop.mjs +30 -26
  73. package/src/core/dream.mjs +5 -17
  74. package/src/core/fact-seeker.mjs +53 -368
  75. package/src/core/lan-discovery.mjs +3 -3
  76. package/src/core/mesh-access-sync.mjs +134 -0
  77. package/src/core/mesh-access.mjs +18 -3
  78. package/src/core/mesh-discover.mjs +173 -0
  79. package/src/core/mesh.mjs +131 -32
  80. package/src/core/optimizer.mjs +4 -41
  81. package/src/core/plugin-bundle.mjs +167 -0
  82. package/src/core/plugin-context.mjs +8 -13
  83. package/src/core/plugin-loader.mjs +272 -68
  84. package/src/core/plugin-peers.mjs +163 -0
  85. package/src/core/plugin-public.mjs +85 -0
  86. package/src/core/plugin-runner-child.mjs +24 -0
  87. package/src/core/plugin-runner.mjs +102 -0
  88. package/src/core/plugin-store.mjs +384 -0
  89. package/src/core/plugin-tasks.mjs +212 -0
  90. package/src/core/post-mortem.mjs +40 -9
  91. package/src/core/research-gate.mjs +182 -0
  92. package/src/core/research-queue.mjs +47 -7
  93. package/src/core/research-surface.mjs +211 -0
  94. package/src/core/research.mjs +25 -32
  95. package/src/core/scheduler.mjs +67 -153
  96. package/src/core/schema.mjs +129 -0
  97. package/src/core/search.mjs +12 -1
  98. package/src/core/secret-integration-research.mjs +12 -8
  99. package/src/core/secrets-store.mjs +32 -2
  100. package/src/core/session-watcher.mjs +6 -0
  101. package/src/core/surface.mjs +64 -3
  102. package/src/core/task-executors.mjs +8 -45
  103. package/src/core/vault-cache.mjs +70 -4
  104. package/src/server/api.mjs +21 -0
  105. package/src/server/rest.mjs +2 -0
  106. package/src/server/route-manifest.json +21 -5
  107. package/src/server/routes/collab.mjs +38 -11
  108. package/src/server/routes/extension.mjs +3 -2
  109. package/src/server/routes/memory.mjs +24 -2
  110. package/src/server/routes/mesh.mjs +7 -7
  111. package/src/server/routes/plugins-mesh.mjs +49 -0
  112. package/src/server/routes/plugins.mjs +91 -426
  113. package/src/server/routes/research.mjs +5 -2
  114. package/src/server/routes/share.mjs +2 -0
  115. package/src/server/tools.mjs +29 -2
  116. package/extension/icons/README.md +0 -27
  117. package/extension/lib/preconfigured.js +0 -5
  118. package/extension/popup/popup.css +0 -227
  119. package/extension/popup/popup.html +0 -50
  120. package/extension/popup/popup.js +0 -175
  121. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  122. package/scaffold/.agent/plugins/.gitkeep +0 -0
  123. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  124. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  125. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  126. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  127. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  129. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  131. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  132. package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Total Recall — Capability Deployment Applier
3
+ *
4
+ * Implements Phase 3 of CAPABILITY_DEPLOYMENT_PLUGINS:
5
+ * - Preflight validation and hash verification
6
+ * - Staged atomic file application with backup journaling
7
+ * - Rollback on failure with preservation of tenant-private data
8
+ * - Truthful installation status and repair records
9
+ * - SSSS event envelopes and access-grant persistence
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import crypto from 'node:crypto';
16
+ import { createDeploymentPlan } from './plan.mjs';
17
+ import { resolveCapabilitySource } from './source.mjs';
18
+ import {
19
+ AppCapabilityInstallationSchema,
20
+ AccessGrantSchema
21
+ } from '../schema.mjs';
22
+
23
+ export class AppApplyPreflightError extends Error {
24
+ constructor(message, details = {}) {
25
+ super(message);
26
+ this.name = 'AppApplyPreflightError';
27
+ this.details = details;
28
+ }
29
+ }
30
+
31
+ export class AppApplyError extends Error {
32
+ constructor(message, details = {}) {
33
+ super(message);
34
+ this.name = 'AppApplyError';
35
+ this.details = details;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Resolves the app vault directory location.
41
+ * Prefers standard SSSS app paths:
42
+ * 1. `<appDir>/vault`
43
+ * 2. `<appDir>/.agent/skills/total-recall/memory-vault`
44
+ * 3. `<appDir>/memory-vault`
45
+ * Defaults to `<appDir>/vault` if none exist yet.
46
+ */
47
+ export function resolveAppVaultDir(appDir) {
48
+ const candidates = [
49
+ path.join(appDir, 'vault'),
50
+ path.join(appDir, '.agent', 'skills', 'total-recall', 'memory-vault'),
51
+ path.join(appDir, 'memory-vault')
52
+ ];
53
+ for (const c of candidates) {
54
+ if (fs.existsSync(c)) return c;
55
+ }
56
+ return path.join(appDir, 'vault');
57
+ }
58
+
59
+ /**
60
+ * Serializes SSSS document frontmatter and markdown body into valid markdown.
61
+ */
62
+ export function formatSsssDocument(frontmatter, body = '') {
63
+ const lines = ['---'];
64
+ for (const [key, value] of Object.entries(frontmatter)) {
65
+ if (value === undefined || value === null) continue;
66
+ if (Array.isArray(value) || (typeof value === 'object' && !(value instanceof Date))) {
67
+ lines.push(`${key}: ${JSON.stringify(value)}`);
68
+ } else if (typeof value === 'string') {
69
+ lines.push(`${key}: ${JSON.stringify(value)}`);
70
+ } else {
71
+ lines.push(`${key}: ${value}`);
72
+ }
73
+ }
74
+ lines.push('---');
75
+ lines.push('');
76
+ if (body) {
77
+ lines.push(body);
78
+ }
79
+ return lines.join('\n');
80
+ }
81
+
82
+ /**
83
+ * Applies a deployment plan to an existing target application.
84
+ *
85
+ * @param {object|string} planOrSource - Precomputed plan object or source path/spec
86
+ * @param {object} [options]
87
+ * @param {string} [options.target] - Target application directory override
88
+ * @param {string} [options.adapter] - Adapter override
89
+ * @param {string} [options.actor] - SSSS actor identity (default: 'total-recall/app-deploy')
90
+ * @param {boolean} [options.dryRun] - Preflight and stage only, do not mutate target app
91
+ * @param {boolean} [options.force] - Bypass identical check
92
+ * @returns {Promise<{
93
+ * success: boolean,
94
+ * plan_hash: string,
95
+ * capabilities: string[],
96
+ * applied_files: number,
97
+ * installed_at: string,
98
+ * dryRun?: boolean,
99
+ * already_applied?: boolean
100
+ * }>}
101
+ */
102
+ export async function applyDeploymentPlan(planOrSource, options = {}) {
103
+ let plan = planOrSource;
104
+ let shouldCleanupPlan = false;
105
+
106
+ // 1. Resolve source to plan if string passed
107
+ if (typeof planOrSource === 'string') {
108
+ plan = await createDeploymentPlan(planOrSource, options);
109
+ shouldCleanupPlan = true;
110
+ }
111
+
112
+ if (!plan || typeof plan !== 'object') {
113
+ throw new AppApplyPreflightError('Invalid deployment plan provided');
114
+ }
115
+
116
+ // 2. Preflight validation
117
+ if (!plan.valid || (plan.conflicts && plan.conflicts.length > 0)) {
118
+ throw new AppApplyPreflightError(
119
+ `Cannot apply invalid deployment plan: contains ${plan.conflicts?.length || 0} conflict(s)`
120
+ );
121
+ }
122
+
123
+ const targetDir = options.target || plan.target_app?.dir || process.cwd();
124
+ if (!fs.existsSync(targetDir)) {
125
+ fs.mkdirSync(targetDir, { recursive: true });
126
+ }
127
+
128
+ const actor = options.actor || 'total-recall/app-deploy';
129
+ const vaultDir = resolveAppVaultDir(targetDir);
130
+ const capInstallDir = path.join(vaultDir, 'system', 'capabilities');
131
+ const grantDir = path.join(vaultDir, 'system', 'grants');
132
+ const eventDir = path.join(vaultDir, 'system', 'events');
133
+
134
+ // Check idempotency: if all file operations are 'identical' and install records exist
135
+ const nonIdenticalOps = (plan.file_operations || []).filter((op) => op.action !== 'identical');
136
+ let allRecordsMatch = true;
137
+ for (const cap of plan.capabilities || []) {
138
+ const recordFile = path.join(capInstallDir, `${cap.id}.md`);
139
+ if (!fs.existsSync(recordFile)) {
140
+ allRecordsMatch = false;
141
+ break;
142
+ }
143
+ }
144
+
145
+ if (nonIdenticalOps.length === 0 && allRecordsMatch && !options.force) {
146
+ if (shouldCleanupPlan && plan.cleanup) plan.cleanup();
147
+ return {
148
+ success: true,
149
+ already_applied: true,
150
+ plan_hash: plan.plan_hash,
151
+ capabilities: (plan.capabilities || []).map((c) => c.id),
152
+ applied_files: 0,
153
+ installed_at: new Date().toISOString()
154
+ };
155
+ }
156
+
157
+ // 3. Prepare staged files
158
+ const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-apply-stage-'));
159
+ const backupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-apply-backup-'));
160
+ const backups = [];
161
+ const stagedSourceDirs = plan._stagedSources || {};
162
+
163
+ try {
164
+ for (const op of plan.file_operations || []) {
165
+ if (op.action === 'identical') continue;
166
+
167
+ let sourceFilePath = null;
168
+ const pluginStagedDir = stagedSourceDirs[op.plugin_id];
169
+ if (pluginStagedDir && fs.existsSync(path.join(pluginStagedDir, op.path))) {
170
+ sourceFilePath = path.join(pluginStagedDir, op.path);
171
+ } else {
172
+ // Fallback: resolve capability source
173
+ const resolved = await resolveCapabilitySource(op.plugin_id, { projectRoot: targetDir });
174
+ sourceFilePath = path.join(resolved.stagedDir, op.path);
175
+ }
176
+
177
+ if (!sourceFilePath || !fs.existsSync(sourceFilePath)) {
178
+ throw new AppApplyPreflightError(
179
+ `Missing source file for operation '${op.path}' in plugin '${op.plugin_id}'`
180
+ );
181
+ }
182
+
183
+ const fileData = fs.readFileSync(sourceFilePath);
184
+ const computedSha = crypto.createHash('sha256').update(fileData).digest('hex');
185
+ if (computedSha !== op.source_sha256) {
186
+ throw new AppApplyPreflightError(
187
+ `Integrity error: source file '${op.path}' sha256 mismatch (expected ${op.source_sha256}, got ${computedSha})`
188
+ );
189
+ }
190
+
191
+ const stagedDest = path.join(stageDir, op.path);
192
+ fs.mkdirSync(path.dirname(stagedDest), { recursive: true });
193
+ fs.writeFileSync(stagedDest, fileData);
194
+ }
195
+
196
+ // Dry-run returns here safely before touching target app
197
+ if (options.dryRun) {
198
+ fs.rmSync(stageDir, { recursive: true, force: true });
199
+ fs.rmSync(backupDir, { recursive: true, force: true });
200
+ if (shouldCleanupPlan && plan.cleanup) plan.cleanup();
201
+ return {
202
+ success: true,
203
+ dryRun: true,
204
+ plan_hash: plan.plan_hash,
205
+ capabilities: (plan.capabilities || []).map((c) => c.id),
206
+ operations: plan.file_operations?.length || 0
207
+ };
208
+ }
209
+
210
+ // 4. Atomic application with backup journaling
211
+ for (const op of plan.file_operations || []) {
212
+ if (op.action === 'identical') continue;
213
+
214
+ const targetPath = path.join(targetDir, op.path);
215
+ if (fs.existsSync(targetPath)) {
216
+ const backupPath = path.join(backupDir, op.path);
217
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
218
+ fs.copyFileSync(targetPath, backupPath);
219
+ backups.push({ targetPath, backupPath, isNew: false });
220
+ } else {
221
+ backups.push({ targetPath, isNew: true });
222
+ }
223
+
224
+ const stagedFile = path.join(stageDir, op.path);
225
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
226
+ fs.copyFileSync(stagedFile, targetPath);
227
+ }
228
+
229
+ // 5. App-owned SSSS Installation records
230
+ fs.mkdirSync(capInstallDir, { recursive: true });
231
+ fs.mkdirSync(grantDir, { recursive: true });
232
+ fs.mkdirSync(eventDir, { recursive: true });
233
+
234
+ const now = new Date().toISOString();
235
+
236
+ for (const cap of plan.capabilities || []) {
237
+ const capFiles = (plan.file_operations || [])
238
+ .filter((o) => o.plugin_id === cap.id)
239
+ .map((o) => ({
240
+ path: o.path,
241
+ sha256: o.source_sha256,
242
+ size: o.size
243
+ }));
244
+
245
+ const recordFrontmatter = {
246
+ type: 'app_capability_installation',
247
+ title: `Installed Capability: ${cap.id}`,
248
+ description: `Capability ${cap.id} v${cap.version} deployed into application`,
249
+ timestamp: now,
250
+ capability_id: cap.id,
251
+ version: cap.version,
252
+ source_sha256: cap.source_sha256,
253
+ adapter: plan.adapter,
254
+ status: 'installed',
255
+ access_grants: plan.access_grants || [],
256
+ resources: plan.resources || {},
257
+ files: capFiles,
258
+ plan_hash: plan.plan_hash,
259
+ installed_at: now,
260
+ updated_at: now
261
+ };
262
+
263
+ // Validate against SSSS schema
264
+ AppCapabilityInstallationSchema.parse(recordFrontmatter);
265
+
266
+ const recordMarkdown = formatSsssDocument(
267
+ recordFrontmatter,
268
+ `# Capability: ${cap.id}\n\nInstalled v${cap.version} with plan hash \`${plan.plan_hash}\`.\n`
269
+ );
270
+
271
+ const recordFile = path.join(capInstallDir, `${cap.id}.md`);
272
+ fs.writeFileSync(recordFile, recordMarkdown, 'utf8');
273
+ }
274
+
275
+ // 6. Access grant records
276
+ if (plan.access_grants && plan.access_grants.length > 0) {
277
+ for (const cap of plan.capabilities || []) {
278
+ const grantFrontmatter = {
279
+ type: 'access_grant',
280
+ title: `Access Grant: ${cap.id}`,
281
+ description: `Access grants for capability ${cap.id}`,
282
+ timestamp: now,
283
+ grantee: cap.id,
284
+ grantee_type: 'capability',
285
+ scopes: plan.access_grants,
286
+ resources: Object.keys(plan.resources || {}),
287
+ status: 'active',
288
+ granted_at: now,
289
+ granted_by: actor
290
+ };
291
+
292
+ AccessGrantSchema.parse(grantFrontmatter);
293
+
294
+ const grantMarkdown = formatSsssDocument(
295
+ grantFrontmatter,
296
+ `# Access Grant for ${cap.id}\n\nGranted by \`${actor}\` at ${now}.\n`
297
+ );
298
+ fs.writeFileSync(path.join(grantDir, `${cap.id}.md`), grantMarkdown, 'utf8');
299
+ }
300
+ }
301
+
302
+ // 7. Append-only SSSS event log
303
+ const eventLogFile = path.join(eventDir, 'capability-events.jsonl');
304
+ const eventLines = (plan.ssss_envelopes || []).map((env) => JSON.stringify(env)).join('\n') + '\n';
305
+ fs.appendFileSync(eventLogFile, eventLines, 'utf8');
306
+
307
+ // Clean up temporary staging and backup directories
308
+ fs.rmSync(stageDir, { recursive: true, force: true });
309
+ fs.rmSync(backupDir, { recursive: true, force: true });
310
+ if (shouldCleanupPlan && plan.cleanup) plan.cleanup();
311
+
312
+ return {
313
+ success: true,
314
+ plan_hash: plan.plan_hash,
315
+ capabilities: (plan.capabilities || []).map((c) => c.id),
316
+ applied_files: nonIdenticalOps.length,
317
+ installed_at: now
318
+ };
319
+ } catch (err) {
320
+ // 8. Rollback: restore previous files, delete created files
321
+ for (const b of [...backups].reverse()) {
322
+ try {
323
+ if (b.isNew) {
324
+ if (fs.existsSync(b.targetPath)) {
325
+ fs.rmSync(b.targetPath, { force: true });
326
+ }
327
+ } else if (b.backupPath && fs.existsSync(b.backupPath)) {
328
+ fs.copyFileSync(b.backupPath, b.targetPath);
329
+ }
330
+ } catch {}
331
+ }
332
+
333
+ // Write repair status to vault so the failure is truthful
334
+ try {
335
+ fs.mkdirSync(capInstallDir, { recursive: true });
336
+ fs.mkdirSync(eventDir, { recursive: true });
337
+ for (const cap of plan.capabilities || []) {
338
+ const failedRecord = {
339
+ type: 'app_capability_installation',
340
+ title: `Failed Capability: ${cap.id}`,
341
+ description: `Capability ${cap.id} v${cap.version} installation failed`,
342
+ timestamp: new Date().toISOString(),
343
+ capability_id: cap.id,
344
+ version: cap.version,
345
+ source_sha256: cap.source_sha256,
346
+ adapter: plan.adapter || 'unknown',
347
+ status: 'failed',
348
+ access_grants: plan.access_grants || [],
349
+ resources: {},
350
+ files: [],
351
+ plan_hash: plan.plan_hash,
352
+ installed_at: new Date().toISOString(),
353
+ updated_at: new Date().toISOString(),
354
+ error: err.message
355
+ };
356
+ const failedMd = formatSsssDocument(failedRecord, `# Installation Failed\n\nError: ${err.message}\n`);
357
+ fs.writeFileSync(path.join(capInstallDir, `${cap.id}.md`), failedMd, 'utf8');
358
+ }
359
+
360
+ const failEvent = {
361
+ type: 'event',
362
+ domain: 'ssss.capability',
363
+ action: 'install_failed',
364
+ timestamp: new Date().toISOString(),
365
+ actor,
366
+ target: targetDir,
367
+ payload: {
368
+ error: err.message
369
+ }
370
+ };
371
+ fs.appendFileSync(path.join(eventDir, 'capability-events.jsonl'), JSON.stringify(failEvent) + '\n', 'utf8');
372
+ } catch {}
373
+
374
+ // Cleanup temp dirs
375
+ try {
376
+ if (fs.existsSync(stageDir)) fs.rmSync(stageDir, { recursive: true, force: true });
377
+ if (fs.existsSync(backupDir)) fs.rmSync(backupDir, { recursive: true, force: true });
378
+ if (shouldCleanupPlan && plan.cleanup) plan.cleanup();
379
+ } catch {}
380
+
381
+ throw new AppApplyError(`Failed to apply capability deployment: ${err.message}`, {
382
+ rollbackPerformed: true,
383
+ originalError: err
384
+ });
385
+ }
386
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Total Recall — Design Token Parser & Validator
3
+ *
4
+ * Implements Phase 2B of CAPABILITY_DEPLOYMENT_PLUGINS:
5
+ * - Parses `DESIGN.md` YAML frontmatter into canonical design tokens
6
+ * - Resolves token references and compiles standard CSS variables (`:root { ... }`)
7
+ * - Validates plugin UI elements to reject hardcoded brand values and enforce design token compliance
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ // Matches kebab-case transformation
14
+ function toKebabCase(str) {
15
+ return str
16
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
17
+ .replace(/[\s_]+/g, '-')
18
+ .toLowerCase();
19
+ }
20
+
21
+ /**
22
+ * Extracts YAML frontmatter from a markdown string.
23
+ * @param {string} content
24
+ * @returns {object}
25
+ */
26
+ export function extractFrontmatter(content) {
27
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
28
+ if (!match) return {};
29
+ const raw = match[1];
30
+
31
+ // Lightweight YAML parser for nested token structures
32
+ const result = {};
33
+ const lines = raw.split('\n');
34
+ const stack = [{ obj: result, indent: -1 }];
35
+
36
+ for (const line of lines) {
37
+ if (!line.trim() || line.trim().startsWith('#')) continue;
38
+ const indent = line.search(/\S/);
39
+ const trimmed = line.trim();
40
+ const colonIdx = trimmed.indexOf(':');
41
+ if (colonIdx === -1) continue;
42
+
43
+ const key = trimmed.slice(0, colonIdx).trim();
44
+ let val = trimmed.slice(colonIdx + 1).trim();
45
+
46
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
47
+ stack.pop();
48
+ }
49
+ const current = stack[stack.length - 1].obj;
50
+
51
+ if (val === '') {
52
+ current[key] = {};
53
+ stack.push({ obj: current[key], indent });
54
+ } else {
55
+ if (
56
+ (val.startsWith('"') && val.endsWith('"')) ||
57
+ (val.startsWith("'") && val.endsWith("'"))
58
+ ) {
59
+ val = val.slice(1, -1);
60
+ }
61
+ current[key] = val;
62
+ }
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Resolves token references like "{colors.primary}" within values.
70
+ * @param {object} tokens
71
+ * @returns {object}
72
+ */
73
+ export function resolveTokenReferences(tokens) {
74
+ function getNested(obj, pathParts) {
75
+ let curr = obj;
76
+ for (const p of pathParts) {
77
+ if (curr === undefined || curr === null) return undefined;
78
+ curr = curr[p];
79
+ }
80
+ return curr;
81
+ }
82
+
83
+ function resolveVal(val) {
84
+ if (typeof val !== 'string') return val;
85
+ return val.replace(/\{([a-zA-Z0-9_.-]+)\}/g, (match, tokenPath) => {
86
+ const parts = tokenPath.split('.');
87
+ const resolved = getNested(tokens, parts);
88
+ return resolved !== undefined ? resolveVal(resolved) : match;
89
+ });
90
+ }
91
+
92
+ function walk(node) {
93
+ if (typeof node !== 'object' || node === null) return resolveVal(node);
94
+ const out = {};
95
+ for (const [k, v] of Object.entries(node)) {
96
+ if (typeof v === 'object' && v !== null) {
97
+ out[k] = walk(v);
98
+ } else {
99
+ out[k] = resolveVal(v);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ return walk(tokens);
106
+ }
107
+
108
+ /**
109
+ * Compiles parsed tokens into standard CSS custom properties.
110
+ *
111
+ * @param {object} tokens - Parsed design tokens
112
+ * @returns {string} Compiled CSS block
113
+ */
114
+ export function generateCssVariables(tokens) {
115
+ const vars = [];
116
+ const resolved = resolveTokenReferences(tokens);
117
+
118
+ // 1. Colors
119
+ if (resolved.colors) {
120
+ for (const [name, val] of Object.entries(resolved.colors)) {
121
+ if (typeof val === 'string') {
122
+ vars.push(` --color-${toKebabCase(name)}: ${val};`);
123
+ }
124
+ }
125
+ }
126
+
127
+ // 2. Typography
128
+ if (resolved.typography) {
129
+ if (resolved.typography.fontFamily) {
130
+ for (const [name, val] of Object.entries(resolved.typography.fontFamily)) {
131
+ if (typeof val === 'string') {
132
+ vars.push(` --font-family-${toKebabCase(name)}: ${val};`);
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ // 3. Spacing
139
+ if (resolved.spacing) {
140
+ for (const [name, val] of Object.entries(resolved.spacing)) {
141
+ if (typeof val === 'string') {
142
+ vars.push(` --spacing-${toKebabCase(name)}: ${val};`);
143
+ }
144
+ }
145
+ }
146
+
147
+ // 4. Border Radius (rounded)
148
+ const rounded = resolved.rounded || resolved.borderRadius;
149
+ if (rounded) {
150
+ for (const [name, val] of Object.entries(rounded)) {
151
+ if (typeof val === 'string') {
152
+ vars.push(` --radius-${toKebabCase(name)}: ${val};`);
153
+ }
154
+ }
155
+ }
156
+
157
+ return `:root {\n${vars.join('\n')}\n}\n`;
158
+ }
159
+
160
+ /**
161
+ * Parses DESIGN.md from a path or string and compiles to CSS variables.
162
+ * @param {string} source - Path to DESIGN.md or raw content
163
+ * @returns {{ tokens: object, css: string }}
164
+ */
165
+ export function parseDesignTokens(source) {
166
+ let content = source;
167
+ if (fs.existsSync(source)) {
168
+ content = fs.readFileSync(source, 'utf8');
169
+ }
170
+
171
+ const rawTokens = extractFrontmatter(content);
172
+ const resolved = resolveTokenReferences(rawTokens);
173
+ const css = generateCssVariables(resolved);
174
+
175
+ return {
176
+ tokens: resolved,
177
+ css
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Prohibited patterns: hardcoded hex colors, rgb/hsl, hardcoded px fonts in UI code
183
+ * when design tokens should be referenced via var(--...).
184
+ */
185
+ const HARDCODED_COLOR_REGEX = /(?:color|background|border|fill|stroke)\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()/i;
186
+
187
+ /**
188
+ * Validates plugin UI source code against design token compliance.
189
+ * Rejects hardcoded brand values and ensures use of CSS custom properties.
190
+ *
191
+ * @param {string} uiSource - Source code of UI component (HTML, JSX, CSS, Web Component)
192
+ * @param {object} [options]
193
+ * @returns {{ valid: boolean, violations: string[] }}
194
+ */
195
+ export function validatePluginUiAgainstTokens(uiSource, options = {}) {
196
+ const violations = [];
197
+ const lines = uiSource.split('\n');
198
+
199
+ for (let i = 0; i < lines.length; i++) {
200
+ const line = lines[i];
201
+
202
+ // Check for hardcoded color values in styles
203
+ if (HARDCODED_COLOR_REGEX.test(line)) {
204
+ // Allow var(--...) even if it has fallback or comments
205
+ if (!line.includes('var(--')) {
206
+ violations.push(
207
+ `Line ${i + 1}: Hardcoded color value detected ('${line.trim()}'). Use design token CSS variable var(--color-...) instead.`
208
+ );
209
+ }
210
+ }
211
+ }
212
+
213
+ return {
214
+ valid: violations.length === 0,
215
+ violations
216
+ };
217
+ }