total-recall-brain 3.31.2 → 3.32.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 (34) hide show
  1. package/bin/total-recall.mjs +2 -2
  2. package/package.json +2 -2
  3. package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
  4. package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
  5. package/src/cli/app/index.mjs +181 -1
  6. package/src/cli/command.mjs +269 -5
  7. package/src/cli/connect.mjs +1 -1
  8. package/src/cli/daemon.mjs +3 -3
  9. package/src/cli/init.mjs +54 -0
  10. package/src/cli/plugin/create.mjs +20 -10
  11. package/src/cli/repo-expert-generate.mjs +37 -3
  12. package/src/cli/skill-config.mjs +211 -0
  13. package/src/cli/skill.mjs +37 -2
  14. package/src/core/app-deploy/adapter-contract.mjs +176 -0
  15. package/src/core/app-deploy/design-tokens.mjs +148 -108
  16. package/src/core/app-deploy/plan.mjs +2 -2
  17. package/src/core/app-deploy/resolve.mjs +30 -2
  18. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
  19. package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
  20. package/src/core/app-deploy/runtime-cli.mjs +254 -0
  21. package/src/core/app-deploy/ui-elements.mjs +473 -0
  22. package/src/core/app-deploy/verify.mjs +18 -0
  23. package/src/core/command-surface.mjs +129 -0
  24. package/src/core/daemon-control.mjs +31 -12
  25. package/src/core/daemon-loop.mjs +14 -7
  26. package/src/core/plugin-loader.mjs +12 -12
  27. package/src/core/schema.mjs +72 -13
  28. package/src/core/skill-config.mjs +376 -0
  29. package/src/core/skills-registry.mjs +147 -31
  30. package/src/core/surface.mjs +52 -7
  31. package/src/server/routes/system.mjs +1 -1
  32. package/plugins/code-quality/README.md +0 -25
  33. package/plugins/code-quality/cli.mjs +0 -25
  34. package/plugins/code-quality/plugin.json +0 -17
@@ -1,17 +1,166 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { spawn } from 'node:child_process';
3
5
  import { resolveBrainLayer } from '../core/config.mjs';
6
+ import { isSafeRelativePath, validatePluginManifest } from '../core/plugin-loader.mjs';
7
+
8
+ const COMMAND_NAME = /^[a-z][a-z0-9-]{0,63}$/;
9
+
10
+ function safeCommandName(name) {
11
+ if (!COMMAND_NAME.test(name || '')) throw new Error('Command name must be lowercase kebab-case (1–64 characters)');
12
+ return name;
13
+ }
14
+
15
+ /** Generate a composable command from one validated plugin manifest entry. */
16
+ export function generatePluginCommand(pluginDir, name, commandsDir) {
17
+ safeCommandName(name);
18
+ const root = fs.realpathSync(pluginDir);
19
+ const manifestPath = path.join(root, 'plugin.json');
20
+ if (!fs.statSync(manifestPath).isFile() || fs.lstatSync(manifestPath).isSymbolicLink()) {
21
+ throw new Error('Plugin manifest must be a regular file');
22
+ }
23
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
24
+ const validation = validatePluginManifest(manifest);
25
+ if (!validation.valid) throw new Error(`Invalid plugin manifest: ${validation.errors.join('; ')}`);
26
+ const spec = (manifest.commands || []).find((entry) => entry.name === name);
27
+ if (!spec) throw new Error(`Plugin '${manifest.id}' has no command '${name}'`);
28
+ if (!isSafeRelativePath(spec.handler) || spec.handler.includes('\\') || !spec.handler.endsWith('.mjs')) {
29
+ throw new Error(`Command '${name}' needs a safe .mjs handler path`);
30
+ }
31
+ const handlerPath = path.resolve(root, spec.handler);
32
+ const relative = path.relative(root, handlerPath);
33
+ if (!relative || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)
34
+ || !fs.statSync(handlerPath).isFile() || fs.realpathSync(handlerPath) !== handlerPath) {
35
+ throw new Error(`Command '${name}' handler must be a regular file inside the plugin`);
36
+ }
37
+ fs.mkdirSync(commandsDir, { recursive: true });
38
+ const commandPath = path.join(commandsDir, `${name}.mjs`);
39
+ if (fs.existsSync(commandPath)) throw new Error(`Command '${name}' already exists`);
40
+ const handlerUrl = pathToFileURL(handlerPath).href;
41
+ const content = `// Generated from plugin ${manifest.id}; edit the plugin manifest/handler, then regenerate.
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+ import crypto from 'node:crypto';
45
+ import { spawn } from 'node:child_process';
46
+ import { fileURLToPath } from 'node:url';
47
+
48
+ const name = ${JSON.stringify(name)};
49
+ const description = ${JSON.stringify(spec.description || '')};
50
+ const handlerUrl = ${JSON.stringify(handlerUrl)};
51
+ const backgroundAllowed = ${spec.background === true};
52
+
53
+ export async function run(argv = process.argv, direct = false) {
54
+ const args = argv.slice(direct ? 2 : 3);
55
+ const json = args.includes('--json');
56
+ if (args.includes('--help') || args.includes('-h')) {
57
+ console.log('Usage: total-recall ' + name + ' [args…] [--json]' + (backgroundAllowed ? ' [--background]' : '') + '\\n\\n' + description);
58
+ return;
59
+ }
60
+ try {
61
+ if (args.includes('--background')) {
62
+ if (!backgroundAllowed) throw Object.assign(new Error('Background execution is not enabled for this command'), { exitCode: 2 });
63
+ const reports = path.join(path.dirname(fileURLToPath(import.meta.url)), 'reports');
64
+ fs.mkdirSync(reports, { recursive: true, mode: 0o700 });
65
+ const report = path.join(reports, name + '-' + crypto.randomUUID() + '.log');
66
+ const fd = fs.openSync(report, 'wx', 0o600);
67
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...args.filter((a) => a !== '--background')], {
68
+ detached: true, stdio: ['ignore', fd, fd]
69
+ });
70
+ fs.closeSync(fd);
71
+ child.unref();
72
+ const status = { ok: true, pid: child.pid, report };
73
+ if (json) console.log(JSON.stringify(status));
74
+ else console.log('Started ' + name + ' in background (pid ' + child.pid + '); report: ' + report);
75
+ return status;
76
+ }
77
+ const handler = await import(handlerUrl);
78
+ const entry = handler.run || handler.default;
79
+ if (typeof entry !== 'function') throw new Error('Plugin command handler must export run() or default function');
80
+ const handlerArgs = args.filter((arg) => arg !== '--json');
81
+ const result = await entry(handler.run ? [process.execPath, 'total-recall', name, ...handlerArgs] : handlerArgs);
82
+ const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : 0;
83
+ if (json) console.log(JSON.stringify({ ok: exitCode === 0, exit_code: exitCode, result: result?.data ?? result ?? null }));
84
+ process.exitCode = exitCode;
85
+ return result;
86
+ } catch (error) {
87
+ const exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
88
+ if (json) console.log(JSON.stringify({ ok: false, exit_code: exitCode, error: error.message }));
89
+ else console.error(error.message);
90
+ process.exitCode = exitCode;
91
+ }
92
+ }
93
+
94
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
95
+ await run(process.argv, true);
96
+ }
97
+ `;
98
+ fs.writeFileSync(commandPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
99
+ return commandPath;
100
+ }
101
+
102
+ /**
103
+ * Generate `total-recall <name> config|<collection>|detect|init` for a layered
104
+ * skill deployed in the repo that owns `commandsDir` (<repo>/.agent/commands).
105
+ * The wrapper holds no config logic: it delegates to the running Total Recall
106
+ * CLI, so the layer contract in the skill core stays the only source of truth.
107
+ */
108
+ export function generateSkillConfigCommand(skillId, name, commandsDir) {
109
+ safeCommandName(name);
110
+ safeCommandName(skillId);
111
+ const repoRoot = path.resolve(commandsDir, '..', '..');
112
+ const schemaPath = path.join(repoRoot, '.agent', 'skills', skillId, 'core', 'config.schema.json');
113
+ if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
114
+ throw new Error(`Skill '${skillId}' in ${repoRoot} has no core/config.schema.json; deploy the layered skill first`);
115
+ }
116
+ fs.mkdirSync(commandsDir, { recursive: true });
117
+ const commandPath = path.join(commandsDir, `${name}.mjs`);
118
+ if (fs.existsSync(commandPath)) throw new Error(`Command '${name}' already exists`);
119
+ const fallbackModule = pathToFileURL(path.join(path.dirname(fileURLToPath(import.meta.url)), 'skill-config.mjs')).href;
120
+ const content = `// Generated repo-layer config command for skill ${skillId}; the contract is .agent/skills/${skillId}/core/config.schema.json.
121
+ import fs from 'node:fs';
122
+ import path from 'node:path';
123
+ import { fileURLToPath, pathToFileURL } from 'node:url';
124
+
125
+ const skillId = ${JSON.stringify(skillId)};
126
+ const name = ${JSON.stringify(name)};
127
+ const fallbackModule = ${JSON.stringify(fallbackModule)};
128
+
129
+ // Prefer the Total Recall install that is running this command, so upgrades apply.
130
+ function configModule() {
131
+ try {
132
+ const bin = fs.realpathSync(process.argv[1]);
133
+ const candidate = path.join(path.dirname(path.dirname(bin)), 'src', 'cli', 'skill-config.mjs');
134
+ if (path.basename(bin) === 'total-recall.mjs' && fs.existsSync(candidate)) return pathToFileURL(candidate).href;
135
+ } catch { /* fall back to the generating install */ }
136
+ return fallbackModule;
137
+ }
138
+
139
+ export async function run(argv = process.argv) {
140
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
141
+ const { runSkillConfig } = await import(configModule());
142
+ return runSkillConfig(skillId, argv.slice(3), { repoRoot, prefix: 'total-recall ' + name });
143
+ }
144
+ `;
145
+ fs.writeFileSync(commandPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
146
+ return commandPath;
147
+ }
4
148
 
5
149
  function printHelp() {
6
150
  console.log(`
7
151
  total-recall command — Manage custom composable CLI commands
8
152
 
9
153
  Usage:
10
- total-recall command create <name> "<code>" [--global] Create a new custom CLI command
154
+ total-recall command create <name> "<code>" [--global] [--description "<when to use>"] [--risk <class>]
155
+ Create a custom CLI command (listed in instruction surfaces)
156
+ total-recall command create <name> --from-plugin <dir> Generate from a plugin manifest
157
+ total-recall command create <name> --config-for-skill <id>
158
+ Generate repo-layer config verbs for a
159
+ layered skill deployed in this repo
11
160
  total-recall command read <name> [--global] Read the code of a custom CLI command
12
161
  total-recall command update <name> "<code>" [--global] Update an existing custom CLI command
13
162
  total-recall command remove <name> [--global] Remove a custom CLI command
14
- total-recall command list [--global] List custom CLI commands
163
+ total-recall command list [--global] [--json] List custom CLI commands
15
164
 
16
165
  Examples:
17
166
  npx total-recall command create hello "console.log('Hello from the brain!');"
@@ -49,9 +198,66 @@ export async function run(argv = []) {
49
198
  return commandCmd(args);
50
199
  }
51
200
 
201
+ /**
202
+ * Built-in verbs resolve before custom commands (bin/total-recall.mjs), so a
203
+ * custom command with a built-in name could never run. Read the names from the
204
+ * dispatcher itself so this list cannot drift.
205
+ */
206
+ export function builtinCommandNames() {
207
+ try {
208
+ const bin = fs.readFileSync(new URL('../../bin/total-recall.mjs', import.meta.url), 'utf8');
209
+ const block = bin.match(/^const COMMANDS = \{([\s\S]*?)^\};/m)?.[1] || '';
210
+ return new Set([...block.matchAll(/^\s+'?([a-z][a-z0-9-]*)'?\s*:/gm)].map((m) => m[1]));
211
+ } catch {
212
+ return new Set();
213
+ }
214
+ }
215
+
216
+ const RISKS = new Set(['read', 'write', 'money', 'dns-cert', 'production-deploy', 'secret-revoke']);
217
+
218
+ /** Pull `--flag <value>` pairs out of argv so positional parsing is unaffected. */
219
+ function takeOption(list, flag) {
220
+ const i = list.indexOf(flag);
221
+ if (i === -1) return { list, value: undefined };
222
+ return { list: [...list.slice(0, i), ...list.slice(i + 2)], value: list[i + 1] };
223
+ }
224
+
225
+ function headerTags(description, risk) {
226
+ const one = (v) => String(v).replace(/[\r\n]+/g, ' ').trim();
227
+ return (description ? `// @description ${one(description)}\n` : '') + (risk ? `// @risk ${risk}\n` : '');
228
+ }
229
+
230
+ function existingTag(file, key) {
231
+ try { return fs.readFileSync(file, 'utf8').match(new RegExp(`^// @${key} (.+)$`, 'm'))?.[1] || undefined; } catch { return undefined; }
232
+ }
233
+
234
+ /**
235
+ * Composable instructions: the surfaces list every command, so any change to
236
+ * the command set recompiles them (project layer, or every project for global).
237
+ */
238
+ function recompileSurfaces(isGlobal) {
239
+ if (process.env.TR_COMMAND_NO_COMPILE === '1' || !process.argv[1]) return;
240
+ try {
241
+ const child = spawn(process.execPath, [process.argv[1], 'compile', isGlobal ? '--global' : '--project'], { detached: true, stdio: 'ignore' });
242
+ child.unref();
243
+ console.log(' ⏳ Instruction surfaces recompiling in the background (command list updated).');
244
+ } catch (error) {
245
+ console.warn(` ⚠️ Command saved, but surface recompile failed to start: ${error.message}`);
246
+ }
247
+ }
248
+
52
249
  export default async function commandCmd(rawArgs = []) {
53
250
  const isGlobal = rawArgs.includes('--global') || rawArgs.includes('-g');
54
- const cleanArgs = rawArgs.filter(a => a !== '--global' && a !== '-g');
251
+ const asJson = rawArgs.includes('--json');
252
+ let cleanArgs = rawArgs.filter(a => a !== '--global' && a !== '-g' && a !== '--json');
253
+ let description; let risk;
254
+ ({ list: cleanArgs, value: description } = takeOption(cleanArgs, '--description'));
255
+ ({ list: cleanArgs, value: risk } = takeOption(cleanArgs, '--risk'));
256
+ if (risk !== undefined && !RISKS.has(risk)) {
257
+ console.error(`Error: --risk must be one of ${[...RISKS].join(', ')}`);
258
+ process.exitCode = 2;
259
+ return;
260
+ }
55
261
 
56
262
  const action = cleanArgs[0];
57
263
  const name = cleanArgs[1];
@@ -67,9 +273,50 @@ export default async function commandCmd(rawArgs = []) {
67
273
  process.exit(1);
68
274
  }
69
275
 
276
+ if (name) {
277
+ try { safeCommandName(name); }
278
+ catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 2; return; }
279
+ }
280
+
70
281
  const commandsDir = resolveTargetDir(isGlobal);
71
282
 
72
283
  if (action === 'create') {
284
+ if (builtinCommandNames().has(name)) {
285
+ console.error(`Error: '${name}' is a built-in Total Recall command; a custom command with that name could never run. Choose another name.`);
286
+ process.exitCode = 2;
287
+ return;
288
+ }
289
+ const otherDir = resolveTargetDir(!isGlobal);
290
+ if (path.resolve(otherDir) !== path.resolve(commandsDir) && fs.existsSync(path.join(otherDir, `${name}.mjs`))) {
291
+ console.warn(` ⚠️ A ${isGlobal ? 'project' : 'global'} command '${name}' already exists (${otherDir}); the project one runs first here.`);
292
+ }
293
+ const forSkill = cleanArgs.indexOf('--config-for-skill');
294
+ if (forSkill !== -1) {
295
+ try {
296
+ if (isGlobal) throw new Error('Repo-layer config commands belong to one repository; omit --global');
297
+ if (!cleanArgs[forSkill + 1]) throw new Error('Missing skill id after --config-for-skill');
298
+ const commandPath = generateSkillConfigCommand(cleanArgs[forSkill + 1], name, commandsDir);
299
+ console.log(`✔ Generated npx total-recall ${name} config|detect|init; saved to: ${commandPath}`);
300
+ recompileSurfaces(isGlobal);
301
+ } catch (error) {
302
+ console.error(`Error: ${error.message}`);
303
+ process.exitCode = 1;
304
+ }
305
+ return;
306
+ }
307
+ const fromPlugin = cleanArgs.indexOf('--from-plugin');
308
+ if (fromPlugin !== -1) {
309
+ try {
310
+ if (!cleanArgs[fromPlugin + 1]) throw new Error('Missing plugin directory after --from-plugin');
311
+ const commandPath = generatePluginCommand(cleanArgs[fromPlugin + 1], name, commandsDir);
312
+ console.log(`✔ Generated npx total-recall ${name} from plugin; saved to: ${commandPath}`);
313
+ recompileSurfaces(isGlobal);
314
+ } catch (error) {
315
+ console.error(`Error: ${error.message}`);
316
+ process.exitCode = 1;
317
+ }
318
+ return;
319
+ }
73
320
  const code = cleanArgs[2];
74
321
  if (!code) {
75
322
  console.error(`Error: Missing code snippet for the command.`);
@@ -82,10 +329,15 @@ export default async function commandCmd(rawArgs = []) {
82
329
  }
83
330
 
84
331
  const commandPath = path.join(commandsDir, `${name}.mjs`);
332
+ if (fs.existsSync(commandPath)) {
333
+ console.error(`Error: Custom command '${name}' already exists.`);
334
+ process.exitCode = 1;
335
+ return;
336
+ }
85
337
 
86
338
  // Wrap code in an exported run/default function for composable CLI execution
87
339
  const fileContent = `// Auto-generated custom CLI command: ${name}
88
- export async function run(argv = []) {
340
+ ${headerTags(description, risk)}export async function run(argv = []) {
89
341
  const args = Array.isArray(argv) ? argv.slice(3) : [];
90
342
  ${code}
91
343
  }
@@ -97,6 +349,8 @@ export default async function (args = []) {
97
349
  fs.writeFileSync(commandPath, fileContent, 'utf8');
98
350
  console.log(`\x1b[32m✔ Successfully created custom command: \x1b[1mnpx total-recall ${name}\x1b[0m`);
99
351
  console.log(` Saved to: ${commandPath}`);
352
+ if (!description) console.log(' Tip: add --description "<when to use it>" so the instruction surfaces say when to use this command.');
353
+ recompileSurfaces(isGlobal);
100
354
  } else if (action === 'read') {
101
355
  const commandPath = path.join(commandsDir, `${name}.mjs`);
102
356
  if (fs.existsSync(commandPath)) {
@@ -119,8 +373,11 @@ export default async function (args = []) {
119
373
  process.exit(1);
120
374
  }
121
375
 
376
+ // Keep the declared description/risk unless the update replaces them.
377
+ const keptDescription = description ?? existingTag(commandPath, 'description');
378
+ const keptRisk = risk ?? existingTag(commandPath, 'risk');
122
379
  const fileContent = `// Auto-generated custom CLI command: ${name}
123
- export async function run(argv = []) {
380
+ ${headerTags(keptDescription, keptRisk)}export async function run(argv = []) {
124
381
  const args = Array.isArray(argv) ? argv.slice(3) : [];
125
382
  ${code}
126
383
  }
@@ -132,6 +389,7 @@ export default async function (args = []) {
132
389
  fs.writeFileSync(commandPath, fileContent, 'utf8');
133
390
  console.log(`\x1b[32m✔ Successfully updated custom command: \x1b[1m${name}\x1b[0m`);
134
391
  console.log(` Saved to: ${commandPath}`);
392
+ recompileSurfaces(isGlobal);
135
393
  } else if (action === 'list') {
136
394
  const targets = isGlobal
137
395
  ? [{ label: 'Global', dir: resolveTargetDir(true) }]
@@ -140,6 +398,11 @@ export default async function (args = []) {
140
398
  { label: 'Global', dir: resolveTargetDir(true) }
141
399
  ];
142
400
 
401
+ if (asJson) {
402
+ const { listSurfaceCommands } = await import('../core/command-surface.mjs');
403
+ console.log(JSON.stringify(listSurfaceCommands(targets.map((t) => ({ scope: t.label.toLowerCase(), dir: t.dir })))));
404
+ return;
405
+ }
143
406
  let foundAny = false;
144
407
  for (const t of targets) {
145
408
  if (fs.existsSync(t.dir)) {
@@ -161,6 +424,7 @@ export default async function (args = []) {
161
424
  if (fs.existsSync(commandPath)) {
162
425
  fs.unlinkSync(commandPath);
163
426
  console.log(`\x1b[32m✔ Successfully removed custom command: \x1b[1m${name}\x1b[0m`);
427
+ recompileSurfaces(isGlobal);
164
428
  } else {
165
429
  console.error(`Error: Custom command '${name}' does not exist.`);
166
430
  process.exit(1);
@@ -10,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
10
  // Resolve templates dir relative to this file (src/cli/ → ../../templates/)
11
11
  const TEMPLATES_DIR = path.resolve(__dirname, '..', '..', 'templates');
12
12
 
13
- const CLIENTS = {
13
+ export const CLIENTS = {
14
14
  vscode: {
15
15
  label: 'VS Code Copilot',
16
16
  mode: 'file',
@@ -164,8 +164,8 @@ function directStart() {
164
164
  }
165
165
  }
166
166
 
167
- function directStop() {
168
- const stopped = stopDaemon();
167
+ async function directStop() {
168
+ const stopped = await stopDaemon();
169
169
  if (stopped) {
170
170
  console.error(' ✅ Daemon stopped');
171
171
  } else {
@@ -215,7 +215,7 @@ export default async function daemon(args) {
215
215
  case 'stop':
216
216
  if (useSystemd) systemdStop();
217
217
  else if (useLaunchd) launchctlStop();
218
- else directStop();
218
+ else await directStop();
219
219
  break;
220
220
  case 'status':
221
221
  if (useSystemd) systemdStatus();
package/src/cli/init.mjs CHANGED
@@ -30,7 +30,9 @@ import path from 'node:path';
30
30
  import os from 'node:os';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { exec, spawn, spawnSync } from 'node:child_process';
33
+ import { randomUUID } from 'node:crypto';
33
34
  import { projectSkillsForScope, detectActiveSkillTargets } from './skill-projection.mjs';
35
+ import { ensureRepoExpert } from './repo-expert-generate.mjs';
34
36
  import { writeFileSecure } from '../core/secure-file.mjs';
35
37
 
36
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -262,6 +264,9 @@ export default async function init(args) {
262
264
  }
263
265
 
264
266
  for (const skill of skillsToSeed) {
267
+ // A repository expert contains project facts and must never be copied from
268
+ // the global brain or package scaffold into a different checkout.
269
+ if (isProject && skill === 'repo-expert') continue;
265
270
  let skillSrc = path.join(scaffoldSkillsDir, skill);
266
271
 
267
272
  // For ssss: prefer the npm-installed @ssss/cli package over scaffold
@@ -601,6 +606,17 @@ export default async function init(args) {
601
606
  logStep('3.6/4', 'Ensuring openwiki is present');
602
607
  await ensureOpenWiki(brainDir, isProject, opts.dryRun);
603
608
 
609
+ if (isProject) {
610
+ logStep('3.6/4', 'Ensuring repository-specific expert');
611
+ try {
612
+ const expert = ensureRepoExpert(cwd, { dryRun: opts.dryRun });
613
+ logOk(`repo-expert ${expert.action}: ${path.relative(cwd, expert.destFile)}`);
614
+ } catch (err) {
615
+ logWarn(`Project initialization stopped: ${err.message}`);
616
+ process.exit(1);
617
+ }
618
+ }
619
+
604
620
  // ── Step 3.7: Project skills into the IDEs in use ──
605
621
  // Scope matches the brain: `init --project` projects PROJECT skills into the
606
622
  // repo's IDE skill dirs for IDEs actually used here (.claude/, .agents/, or
@@ -629,6 +645,7 @@ export default async function init(args) {
629
645
  logOk(`${t.label}: ${n} skill(s) → ${path.relative(cwd, t.destDir) || t.destDir}/`);
630
646
  }
631
647
  log(` Discoverable skills: ${skills.map(s => s.name).join(', ')}`);
648
+ if (isProject) await connectDetectedClients(wired);
632
649
  if (available.length > 0) {
633
650
  log(` Also available (opt-in): ${available.map(t => t.clients.join('/')).join(', ')} — \`npx total-recall connect <ide>\``);
634
651
  }
@@ -691,6 +708,11 @@ export default async function init(args) {
691
708
  needsBootstrap = true;
692
709
  }
693
710
 
711
+ if (isProject && !brainCfg.project_id) {
712
+ brainCfg.project_id = randomUUID();
713
+ needsBootstrap = true;
714
+ }
715
+
694
716
  if (needsBootstrap) {
695
717
  try {
696
718
  const { issueKey } = await import('../server/keys.mjs');
@@ -699,6 +721,9 @@ export default async function init(args) {
699
721
  brainCfg.token = brainCfg.token || keyData.token;
700
722
  writeFileSecure(brainJsonPath, JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
701
723
  logOk(`Bootstrap configuration successfully generated and pre-authorized at ${brainCfg.url}`);
724
+ if (brainCfg.project_id) {
725
+ logOk(`Assigned project UUID ${brainCfg.project_id} to project brain`);
726
+ }
702
727
  } catch (err) {
703
728
  logWarn(`Could not auto-generate bootstrap developer key: ${err.message}`);
704
729
  }
@@ -859,7 +884,14 @@ export default async function init(args) {
859
884
  }
860
885
  const projectName = path.basename(cwd);
861
886
  const existingIdx = registry.findIndex(p => p.path === cwd);
887
+ // brainCfg is block-scoped to the bootstrap branch above; re-read the
888
+ // persisted brain.json so the project UUID reaches the registry.
889
+ let projectId;
890
+ try {
891
+ projectId = JSON.parse(fs.readFileSync(path.join(brainDir, 'config', 'brain.json'), 'utf8')).project_id;
892
+ } catch { projectId = undefined; }
862
893
  const entry = {
894
+ project_id: projectId || undefined,
863
895
  name: projectName,
864
896
  path: cwd,
865
897
  brainDir: brainDir,
@@ -921,3 +953,25 @@ export default async function init(args) {
921
953
 
922
954
  process.exit(0);
923
955
  }
956
+
957
+ /**
958
+ * Register every IDE detected as in use as a connected client, so step 4's
959
+ * compile writes that IDE's instruction shim (CLAUDE.md, AGENTS.md, GEMINI.md…).
960
+ * Projecting skills alone left new repos without the global rules.
961
+ */
962
+ export async function connectDetectedClients(wired, { connectFn } = {}) {
963
+ const { default: connect, CLIENTS } = await import('./connect.mjs');
964
+ const run = connectFn || connect;
965
+ const connected = [];
966
+ for (const target of wired) {
967
+ const client = (target.clients || []).find((c) => CLIENTS[c]);
968
+ if (!client || connected.includes(client)) continue;
969
+ try {
970
+ await run([client]);
971
+ connected.push(client);
972
+ } catch (err) {
973
+ logWarn(`Could not connect ${client}: ${err.message} — run \`npx total-recall connect ${client}\``);
974
+ }
975
+ }
976
+ return connected;
977
+ }
@@ -116,10 +116,10 @@ export async function createPlugin(args = []) {
116
116
  const pluginDesc = description || skillFrontmatter.description || `${pluginName} extension for Total Recall AI OS`;
117
117
 
118
118
  const manifest = {
119
- $schema: "https://github.com/total-recall/total-recall/blob/main/metadata.plugin.schema.json",
119
+ $schema: "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
120
120
  id,
121
121
  name: pluginName,
122
- version: "1.0.0",
122
+ version: "0.1.0",
123
123
  description: pluginDesc,
124
124
  license: "MIT"
125
125
  };
@@ -145,8 +145,7 @@ export async function createPlugin(args = []) {
145
145
  command: id,
146
146
  handler: "./cli.mjs",
147
147
  subcommands: [
148
- { name: "status", description: `Show ${pluginName} status` },
149
- { name: "run", description: `Execute ${pluginName} primary task` }
148
+ { name: "status", description: `Show which ${pluginName} subcommands are implemented` }
150
149
  ]
151
150
  };
152
151
 
@@ -155,16 +154,27 @@ export async function createPlugin(args = []) {
155
154
  * ${pluginName} CLI Handler
156
155
  * Executed via: npx total-recall ${id} <subcommand>
157
156
  */
157
+ // Scaffold: report honestly what exists. Add each real subcommand to
158
+ // IMPLEMENTED and to plugin.json "cli.subcommands" as it is built.
159
+ const IMPLEMENTED = {};
160
+
158
161
  export async function run(argv = []) {
159
162
  const args = Array.isArray(argv) ? argv.slice(3) : [];
160
163
  const sub = args[0] || "status";
161
-
164
+ const json = args.includes("--json");
165
+
162
166
  if (sub === "status") {
163
- console.log(\`✅ ${pluginName} v1.0.0 is operational.\`);
167
+ const status = { plugin: ${JSON.stringify(id)}, implemented: Object.keys(IMPLEMENTED) };
168
+ if (json) console.log(JSON.stringify(status));
169
+ else console.log(status.implemented.length
170
+ ? \`${pluginName}: implemented subcommands: \${status.implemented.join(", ")}\`
171
+ : \`${pluginName}: scaffold only — no subcommands implemented yet.\`);
164
172
  return;
165
173
  }
166
-
167
- console.log(\`${pluginName}: executed subcommand "\${sub}" with args:\`, args.slice(1));
174
+ if (IMPLEMENTED[sub]) return IMPLEMENTED[sub](args.slice(1));
175
+
176
+ console.error(\`${pluginName}: "\${sub}" is not implemented.\`);
177
+ process.exitCode = 2;
168
178
  }
169
179
  export default run;
170
180
  `;
@@ -204,7 +214,7 @@ export default run;
204
214
  const skillContent = `---
205
215
  name: ${id}
206
216
  description: "${pluginDesc}"
207
- version: 1.0.0
217
+ version: 0.1.0
208
218
  ---
209
219
 
210
220
  # ${pluginName}
@@ -284,7 +294,7 @@ export default generateContext;
284
294
  "## Features",
285
295
  "",
286
296
  `- **Identifier**: \`${id}\``,
287
- "- **Version**: 1.0.0"
297
+ "- **Version**: 0.1.0"
288
298
  ];
289
299
  if (useCases.length) lines.push(`- **Use cases**: ${useCases.join(", ")}`);
290
300
  if (category) lines.push(`- **SSSS Category**: \`${category}\``);
@@ -13,6 +13,11 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { execSync } from 'node:child_process';
15
15
 
16
+ const SKIP_SCAN_DIRS = new Set([
17
+ '.agent', '.agents', '.claude', '.git', '.venv', 'node_modules', 'dist',
18
+ 'build', 'coverage', '__pycache__', 'vault', 'logs', 'vendor',
19
+ ]);
20
+
16
21
  /**
17
22
  * Scan a repo and return structured architecture data.
18
23
  */
@@ -83,7 +88,7 @@ export function scanRepo(repoRoot) {
83
88
  try {
84
89
  const entries = fs.readdirSync(dir, { withFileTypes: true });
85
90
  for (const entry of entries) {
86
- if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') continue;
91
+ if (entry.name.startsWith('.') || SKIP_SCAN_DIRS.has(entry.name)) continue;
87
92
  const full = path.join(dir, entry.name);
88
93
  if (entry.isDirectory()) {
89
94
  walkForExtensions(full, depth + 1);
@@ -119,8 +124,7 @@ export function scanRepo(repoRoot) {
119
124
  try {
120
125
  const entries = fs.readdirSync(dir, { withFileTypes: true });
121
126
  for (const entry of entries) {
122
- if (entry.name.startsWith('.') && entry.name !== '.agent' && entry.name !== '.agents') continue;
123
- if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build' || entry.name === '.git') continue;
127
+ if (entry.name.startsWith('.') || SKIP_SCAN_DIRS.has(entry.name)) continue;
124
128
  const full = path.join(dir, entry.name);
125
129
  if (entry.isDirectory()) {
126
130
  const childCount = countChildren(full);
@@ -330,6 +334,7 @@ export function generateSkillMd(scan, repoRoot) {
330
334
  lines.push(' structure, and runtime topology. MANDATORY: You MUST read the full SKILL.md');
331
335
  lines.push(' file before executing.');
332
336
  lines.push('repo_scoped: true');
337
+ lines.push(`repository_id: ${JSON.stringify(scan.name || path.basename(path.resolve(repoRoot)))}`);
333
338
  lines.push(`generated_at: ${new Date().toISOString()}`);
334
339
  // Record the repo by name, never by absolute path. SKILL.md is committed, so
335
340
  // an absolute root bakes the generating machine's home directory into the
@@ -520,6 +525,35 @@ export function generateRepoExpert(repoRoot, opts = {}) {
520
525
  };
521
526
  }
522
527
 
528
+ /** Preserve a local expert and reject one that identifies another repository. */
529
+ export function ensureRepoExpert(repoRoot, opts = {}) {
530
+ const root = path.resolve(repoRoot);
531
+ const destFile = path.join(root, '.agent', 'skills', 'repo-expert', 'SKILL.md');
532
+ let repositoryId = path.basename(root);
533
+ try {
534
+ repositoryId = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name || repositoryId;
535
+ } catch { /* non-Node repositories use the checkout name */ }
536
+
537
+ if (fs.existsSync(destFile)) {
538
+ const content = fs.readFileSync(destFile, 'utf8');
539
+ const header = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] || '';
540
+ const scoped = /^repo_scoped:\s*true\s*$/m.test(header);
541
+ const id = header.match(/^repository_id:\s*(.+)\s*$/m)?.[1]?.trim();
542
+ const generatedFrom = header.match(/^generated_from:\s*(.+)\s*$/m)?.[1]?.trim();
543
+ const parsedId = id?.replace(/^["']|["']$/g, '');
544
+ const parsedFrom = generatedFrom?.replace(/^["']|["']$/g, '');
545
+ if (!scoped || (parsedId ? parsedId !== repositoryId :
546
+ !parsedFrom || parsedFrom !== path.basename(root))) {
547
+ throw new Error(`Existing repo-expert does not match ${repositoryId}: ${destFile}`);
548
+ }
549
+ return { action: 'existing', destFile, repositoryId };
550
+ }
551
+
552
+ if (opts.dryRun) return { action: 'would-generate', destFile, repositoryId };
553
+ generateRepoExpert(root);
554
+ return { action: 'generated', destFile, repositoryId };
555
+ }
556
+
523
557
  /**
524
558
  * CLI handler for `npx total-recall skill generate-expert`
525
559
  */