total-recall-brain 3.28.2 → 3.30.1
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/bin/total-recall.mjs +24 -1
- package/docs/ARCHITECTURE.md +26 -18
- package/extension/background.js +259 -84
- package/extension/content-script.css +7 -13
- package/extension/content-script.js +252 -477
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/icons/mark.svg +6 -0
- package/extension/lib/brain-client.js +160 -73
- package/extension/lib/tokens.css +187 -0
- package/extension/lib/ui-helpers.js +229 -0
- package/extension/lib/ui-helpers.spec.mjs +94 -0
- package/extension/manifest.json +20 -5
- package/extension/options/options.css +64 -218
- package/extension/options/options.html +91 -43
- package/extension/options/options.js +208 -85
- package/extension/sidepanel/sidepanel.css +268 -970
- package/extension/sidepanel/sidepanel.html +200 -166
- package/extension/sidepanel/sidepanel.js +788 -942
- package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/plugins.ts +124 -92
- package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
- package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
- package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
- package/frontend/src/pages/PluginsPage.tsx +454 -1218
- package/package.json +2 -1
- package/plugins/code-quality/README.md +25 -0
- package/plugins/code-quality/cli.mjs +25 -0
- package/plugins/code-quality/plugin.json +17 -0
- package/plugins/git-sentinel/README.md +27 -0
- package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
- package/plugins/git-sentinel/plugin.json +18 -0
- package/plugins/system-monitor/README.md +26 -0
- package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
- package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
- package/plugins/system-monitor/plugin.json +29 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
- package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +22 -6
- package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
- package/src/cli/agent.mjs +1 -1
- package/src/cli/app/index.mjs +493 -0
- package/src/cli/brain.mjs +33 -3
- package/src/cli/command.mjs +80 -41
- package/src/cli/connect.mjs +10 -1
- package/src/cli/harness.mjs +1 -1
- package/src/cli/init.mjs +3 -1
- package/src/cli/mesh.mjs +118 -12
- package/src/cli/plugin/create.mjs +144 -25
- package/src/cli/plugin/index.mjs +83 -53
- package/src/cli/plugin/install.mjs +10 -127
- package/src/cli/plugin/list.mjs +44 -44
- package/src/cli/plugin/remove.mjs +23 -30
- package/src/cli/plugin/scaffold-cmd.mjs +30 -0
- package/src/cli/plugin/search.mjs +108 -23
- package/src/cli/rebuild.mjs +37 -1
- package/src/cli/recall.mjs +17 -2
- package/src/cli/research.mjs +4 -6
- package/src/cli/share.mjs +2 -0
- package/src/core/app-deploy/apply.mjs +386 -0
- package/src/core/app-deploy/design-tokens.mjs +217 -0
- package/src/core/app-deploy/plan.mjs +249 -0
- package/src/core/app-deploy/resolve.mjs +295 -0
- package/src/core/app-deploy/source.mjs +337 -0
- package/src/core/app-deploy/standalone.mjs +248 -0
- package/src/core/app-deploy/upgrade.mjs +256 -0
- package/src/core/app-deploy/verify.mjs +213 -0
- package/src/core/bound-hosts.mjs +1 -1
- package/src/core/brain-registry.mjs +146 -0
- package/src/core/brain-state.json +1 -0
- package/src/core/daemon-loop.mjs +30 -26
- package/src/core/dream.mjs +5 -17
- package/src/core/fact-seeker.mjs +53 -368
- package/src/core/lan-discovery.mjs +3 -3
- package/src/core/mesh-access-sync.mjs +134 -0
- package/src/core/mesh-access.mjs +18 -3
- package/src/core/mesh-discover.mjs +173 -0
- package/src/core/mesh.mjs +131 -32
- package/src/core/optimizer.mjs +4 -41
- package/src/core/plugin-bundle.mjs +167 -0
- package/src/core/plugin-context.mjs +8 -13
- package/src/core/plugin-loader.mjs +272 -68
- package/src/core/plugin-peers.mjs +163 -0
- package/src/core/plugin-public.mjs +85 -0
- package/src/core/plugin-runner-child.mjs +24 -0
- package/src/core/plugin-runner.mjs +102 -0
- package/src/core/plugin-store.mjs +384 -0
- package/src/core/plugin-tasks.mjs +212 -0
- package/src/core/post-mortem.mjs +40 -9
- package/src/core/research-gate.mjs +182 -0
- package/src/core/research-queue.mjs +47 -7
- package/src/core/research-surface.mjs +211 -0
- package/src/core/research.mjs +25 -32
- package/src/core/scheduler.mjs +67 -153
- package/src/core/schema.mjs +129 -0
- package/src/core/search.mjs +12 -1
- package/src/core/secret-integration-research.mjs +12 -8
- package/src/core/secrets-store.mjs +76 -2
- package/src/core/session-watcher.mjs +6 -0
- package/src/core/surface.mjs +64 -3
- package/src/core/task-executors.mjs +8 -45
- package/src/core/vault-cache.mjs +70 -4
- package/src/server/api.mjs +21 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +21 -5
- package/src/server/routes/collab.mjs +38 -11
- package/src/server/routes/extension.mjs +3 -2
- package/src/server/routes/memory.mjs +24 -2
- package/src/server/routes/mesh.mjs +7 -7
- package/src/server/routes/plugins-mesh.mjs +49 -0
- package/src/server/routes/plugins.mjs +91 -426
- package/src/server/routes/research.mjs +5 -2
- package/src/server/routes/share.mjs +2 -0
- package/src/server/tools.mjs +29 -2
- package/extension/icons/README.md +0 -27
- package/extension/lib/preconfigured.js +0 -5
- package/extension/popup/popup.css +0 -227
- package/extension/popup/popup.html +0 -50
- package/extension/popup/popup.js +0 -175
- package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +0 -16
- package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
- package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
- package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
- package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
- package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
- package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
- 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
|
+
}
|