total-recall-brain 3.31.2 → 3.32.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.
Files changed (47) hide show
  1. package/bin/total-recall.mjs +20 -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/api.mjs +133 -0
  6. package/src/cli/app/index.mjs +181 -1
  7. package/src/cli/brief.mjs +195 -0
  8. package/src/cli/command.mjs +269 -5
  9. package/src/cli/config.mjs +25 -7
  10. package/src/cli/connect.mjs +1 -1
  11. package/src/cli/daemon.mjs +3 -3
  12. package/src/cli/group.mjs +151 -0
  13. package/src/cli/init.mjs +54 -0
  14. package/src/cli/plugin/create.mjs +20 -10
  15. package/src/cli/project-id.mjs +43 -0
  16. package/src/cli/repo-expert-generate.mjs +37 -3
  17. package/src/cli/secret.mjs +36 -3
  18. package/src/cli/skill-config.mjs +211 -0
  19. package/src/cli/skill.mjs +37 -2
  20. package/src/core/api-routes.mjs +99 -0
  21. package/src/core/app-deploy/adapter-contract.mjs +176 -0
  22. package/src/core/app-deploy/design-tokens.mjs +148 -108
  23. package/src/core/app-deploy/plan.mjs +2 -2
  24. package/src/core/app-deploy/resolve.mjs +30 -2
  25. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
  26. package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
  27. package/src/core/app-deploy/runtime-cli.mjs +254 -0
  28. package/src/core/app-deploy/ui-elements.mjs +473 -0
  29. package/src/core/app-deploy/verify.mjs +18 -0
  30. package/src/core/command-surface.mjs +129 -0
  31. package/src/core/daemon-control.mjs +31 -12
  32. package/src/core/daemon-loop.mjs +14 -7
  33. package/src/core/plugin-loader.mjs +12 -12
  34. package/src/core/project-id.mjs +72 -0
  35. package/src/core/provider-catalog.mjs +1 -1
  36. package/src/core/schema.mjs +72 -13
  37. package/src/core/secrets-env-export.mjs +3 -4
  38. package/src/core/secrets-store.mjs +30 -29
  39. package/src/core/skill-config.mjs +376 -0
  40. package/src/core/skills-registry.mjs +147 -31
  41. package/src/core/source-adapters.mjs +2 -2
  42. package/src/core/surface.mjs +109 -16
  43. package/src/server/routes/help.mjs +1 -1
  44. package/src/server/routes/system.mjs +1 -1
  45. package/plugins/code-quality/README.md +0 -25
  46. package/plugins/code-quality/cli.mjs +0 -25
  47. package/plugins/code-quality/plugin.json +0 -17
@@ -0,0 +1,211 @@
1
+ /**
2
+ * CLI for a layered skill's repo-layer config. Shared by
3
+ * `total-recall skill config <id> …` and the per-skill command generated by
4
+ * `total-recall command create <name> --config-for-skill <id>`.
5
+ *
6
+ * Exit codes: 0 ok · 1 runtime failure (missing skill/contract, I/O) ·
7
+ * 2 usage or validation error.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createInterface } from 'node:readline/promises';
12
+ import {
13
+ SkillConfigError,
14
+ resolveSkillDir,
15
+ loadConfigSchema,
16
+ validateSkillConfig,
17
+ readSkillConfig,
18
+ readSkillConfigState,
19
+ projectSkillConfig,
20
+ recordPath,
21
+ writeSkillConfig,
22
+ getConfigValue,
23
+ setConfigValue,
24
+ parseCliValue,
25
+ listCollections,
26
+ parseCollectionItem,
27
+ addCollectionItem,
28
+ removeCollectionItem,
29
+ detectSkillConfig,
30
+ missingRequired,
31
+ checkSkillLayerContract,
32
+ } from '../core/skill-config.mjs';
33
+
34
+ function usage(prefix, collections) {
35
+ const nouns = Object.entries(collections)
36
+ .map(([noun, c]) => ` ${prefix} ${noun} list|add|remove Manage '${c.property}' (key: ${c.key})`)
37
+ .join('\n');
38
+ return `Usage:
39
+ ${prefix} config get [path] Print the repo config or one value
40
+ ${prefix} config set <path> <value> Set a value (JSON or plain string), validated
41
+ ${prefix} config unset <path> Remove a value, validated
42
+ ${prefix} config validate Check the record against the contract and config.json for drift
43
+ ${prefix} config rebuild Regenerate config.json from the SSSS record
44
+ ${prefix} config import Record an existing config.json (adoption)
45
+ ${prefix} config check Contract checks: record, validity, drift, gate commands
46
+ ${prefix} config schema Print core/config.schema.json
47
+ ${nouns ? `${nouns}\n` : ''} ${prefix} detect [--apply] [--force] Propose a config from the repo (core/detect.mjs)
48
+ ${prefix} init [--yes] [--force] Create config.json from defaults, detection and prompts
49
+
50
+ Options: --json (structured output) · --repo <path> (with 'skill config')`;
51
+ }
52
+
53
+ function topLevelDefaults(schema) {
54
+ const defaults = {};
55
+ for (const [key, definition] of Object.entries(schema.properties || {})) {
56
+ if (definition && Object.hasOwn(definition, 'default')) defaults[key] = structuredClone(definition.default);
57
+ }
58
+ return defaults;
59
+ }
60
+
61
+ async function promptMissing(schema, config, keys) {
62
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
63
+ try {
64
+ let next = config;
65
+ for (const key of keys) {
66
+ const definition = schema.properties?.[key] || {};
67
+ const hint = definition.enum ? ` (${definition.enum.join('|')})` : definition.type ? ` (${definition.type})` : '';
68
+ const label = definition.description ? `${key} — ${definition.description}` : key;
69
+ const answer = (await rl.question(`${label}${hint}: `)).trim();
70
+ if (!answer) throw new SkillConfigError(`'${key}' is required`);
71
+ next = setConfigValue(next, key, parseCliValue(answer));
72
+ }
73
+ return next;
74
+ } finally {
75
+ rl.close();
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @param {string} skillId
81
+ * @param {string[]} args verb and arguments, e.g. ['config', 'set', 'tier', 'fast']
82
+ * @param {{ repoRoot: string, prefix?: string }} opts
83
+ */
84
+ export async function runSkillConfig(skillId, args, { repoRoot, prefix = `total-recall skill config ${skillId}` } = {}) {
85
+ const json = args.includes('--json');
86
+ const flags = new Set(args.filter((arg) => arg.startsWith('--')));
87
+ const positional = args.filter((arg) => !arg.startsWith('--'));
88
+ const out = (payload, text) => {
89
+ if (json) console.log(JSON.stringify({ ok: true, ...payload }));
90
+ else if (text !== undefined) console.log(text);
91
+ return { ok: true, ...payload };
92
+ };
93
+
94
+ try {
95
+ const skillDir = resolveSkillDir(repoRoot, skillId);
96
+ const contract = loadConfigSchema(skillDir);
97
+ const collections = listCollections(contract.schema);
98
+ const [verb, action, ...rest] = positional;
99
+
100
+ if (!verb || verb === 'help' || flags.has('--help')) {
101
+ console.log(usage(prefix, collections));
102
+ return { ok: true };
103
+ }
104
+
105
+ if (verb === 'config') {
106
+ const state = readSkillConfigState(skillDir);
107
+ const current = state.config;
108
+ if (action === 'schema') return out({ schema: contract.schema }, JSON.stringify(contract.schema, null, 2));
109
+ if (action === 'validate') {
110
+ if (current === null) throw new SkillConfigError('No config yet; run init or detect --apply', { exitCode: 1 });
111
+ const result = validateSkillConfig(contract, current);
112
+ if (!result.valid) throw new SkillConfigError(`${state.source === 'record' ? recordPath(skillId) : 'config.json'} is invalid:\n ${result.errors.join('\n ')}`, { issues: result.errors });
113
+ if (state.source !== 'record') throw new SkillConfigError(`config.json is valid but not recorded in SSSS; run '${prefix} config import'`, { exitCode: 1 });
114
+ if (!state.inSync) throw new SkillConfigError(`config.json has drifted from ${recordPath(skillId)}; run '${prefix} config rebuild'`, { exitCode: 1 });
115
+ return out({ valid: true, in_sync: true }, `${recordPath(skillId)} satisfies core/config.schema.json and config.json matches it`);
116
+ }
117
+ if (action === 'check') {
118
+ const report = checkSkillLayerContract(skillDir);
119
+ const text = report.checks.map((c) => `${c.ok ? '✔' : c.level === 'warn' ? '⚠' : '✖'} ${c.message}`).join('\n');
120
+ if (json) console.log(JSON.stringify({ ok: report.ok, exit_code: report.ok ? 0 : 1, ...report }));
121
+ else console.log(text);
122
+ process.exitCode = report.ok ? 0 : 1;
123
+ return report;
124
+ }
125
+ if (action === 'rebuild') {
126
+ if (state.source !== 'record') throw new SkillConfigError(`No ${recordPath(skillId)} record to rebuild from`, { exitCode: 1 });
127
+ projectSkillConfig(skillDir, state.record.config);
128
+ return out({ config: state.record.config }, '✔ Rebuilt config.json from the SSSS record');
129
+ }
130
+ if (action === 'import') {
131
+ if (state.source === 'record') return out({ config: current, imported: false }, `Already recorded at ${recordPath(skillId)}`);
132
+ if (state.projection === null) throw new SkillConfigError('No config.json to import', { exitCode: 1 });
133
+ const written = await writeSkillConfig(skillDir, contract, state.projection);
134
+ return out({ config: written, imported: true }, `✔ Recorded config.json at ${recordPath(skillId)}`);
135
+ }
136
+ if (action === 'get') {
137
+ const value = rest[0] ? getConfigValue(current ?? {}, rest[0]) : current;
138
+ if (rest[0] && value === undefined) throw new SkillConfigError(`No value at '${rest[0]}'`, { exitCode: 1 });
139
+ return out({ value: value ?? null }, typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2));
140
+ }
141
+ if (action === 'set' || action === 'unset') {
142
+ if (!rest[0] || (action === 'set' && rest.length < 2)) throw new SkillConfigError(`Usage: ${prefix} config ${action} <path>${action === 'set' ? ' <value>' : ''}`);
143
+ if (current === null) throw new SkillConfigError('No config.json yet; run init or detect --apply first', { exitCode: 1 });
144
+ const value = action === 'set' ? parseCliValue(rest.slice(1).join(' ')) : undefined;
145
+ const written = await writeSkillConfig(skillDir, contract, setConfigValue(current, rest[0], value));
146
+ return out({ config: written }, `✔ ${action === 'set' ? 'Set' : 'Removed'} ${rest[0]}`);
147
+ }
148
+ throw new SkillConfigError(`Unknown config action '${action ?? ''}'\n${usage(prefix, collections)}`);
149
+ }
150
+
151
+ if (Object.hasOwn(collections, verb)) {
152
+ const collection = collections[verb];
153
+ const current = readSkillConfig(skillDir);
154
+ if (action === 'list') {
155
+ const items = getConfigValue(current ?? {}, collection.property) ?? [];
156
+ return out({ items }, items.length ? items.map((item) => `- ${item?.[collection.key]}: ${JSON.stringify(item)}`).join('\n') : `No ${collection.property}.`);
157
+ }
158
+ if (current === null) throw new SkillConfigError('No config.json yet; run init or detect --apply first', { exitCode: 1 });
159
+ if (action === 'add') {
160
+ const item = parseCollectionItem(rest);
161
+ const written = await writeSkillConfig(skillDir, contract, addCollectionItem(current, collection, item));
162
+ return out({ config: written }, `✔ Added ${verb} '${item[collection.key]}'`);
163
+ }
164
+ if (action === 'remove') {
165
+ if (!rest[0]) throw new SkillConfigError(`Usage: ${prefix} ${verb} remove <${collection.key}>`);
166
+ const written = await writeSkillConfig(skillDir, contract, removeCollectionItem(current, collection, rest[0]));
167
+ return out({ config: written }, `✔ Removed ${verb} '${rest[0]}'`);
168
+ }
169
+ throw new SkillConfigError(`Usage: ${prefix} ${verb} list|add|remove`);
170
+ }
171
+
172
+ if (verb === 'detect') {
173
+ const proposal = await detectSkillConfig(skillDir, repoRoot);
174
+ const checked = validateSkillConfig(contract, proposal);
175
+ if (!flags.has('--apply')) {
176
+ return out({ proposal, valid: checked.valid, errors: checked.errors },
177
+ `${JSON.stringify(proposal, null, 2)}\n${checked.valid ? 'Valid. Re-run with --apply to write config.json.' : `Invalid:\n ${checked.errors.join('\n ')}`}`);
178
+ }
179
+ if (readSkillConfig(skillDir) !== null && !flags.has('--force')) {
180
+ throw new SkillConfigError('A config already exists; use --force to replace it with the detected config', { exitCode: 1 });
181
+ }
182
+ const written = await writeSkillConfig(skillDir, contract, proposal);
183
+ return out({ config: written }, '✔ Recorded the detected config and wrote config.json');
184
+ }
185
+
186
+ if (verb === 'init') {
187
+ if (readSkillConfig(skillDir) !== null && !flags.has('--force')) {
188
+ throw new SkillConfigError(`A config already exists; change it with '${prefix} config set' or pass --force`, { exitCode: 1 });
189
+ }
190
+ let config = topLevelDefaults(contract.schema);
191
+ if (fs.existsSync(path.join(skillDir, 'core', 'detect.mjs'))) config = { ...config, ...(await detectSkillConfig(skillDir, repoRoot)) };
192
+ const missing = missingRequired(contract.schema, config);
193
+ if (missing.length) {
194
+ if (flags.has('--yes') || json || !process.stdin.isTTY) {
195
+ throw new SkillConfigError(`Missing required fields: ${missing.join(', ')}. Run interactively or set them after 'detect --apply'.`);
196
+ }
197
+ config = await promptMissing(contract.schema, config, missing);
198
+ }
199
+ const written = await writeSkillConfig(skillDir, contract, config);
200
+ return out({ config: written }, `✔ Initialized ${path.relative(process.cwd(), path.join(skillDir, 'config.json')) || 'config.json'}`);
201
+ }
202
+
203
+ throw new SkillConfigError(`Unknown subcommand '${verb}'\n${usage(prefix, collections)}`);
204
+ } catch (error) {
205
+ const exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
206
+ if (json) console.log(JSON.stringify({ ok: false, exit_code: exitCode, error: error.message, ...(error.issues?.length ? { issues: error.issues } : {}) }));
207
+ else console.error(`Error: ${error.message}`);
208
+ process.exitCode = exitCode;
209
+ return { ok: false, exit_code: exitCode, error: error.message };
210
+ }
211
+ }
package/src/cli/skill.mjs CHANGED
@@ -68,6 +68,8 @@ function printHelp() {
68
68
  registry List catalog entries in skills-registry/index.yaml
69
69
  deploy <id> Copy skill into a repo (.agent/skills/<id>/) and record install map
70
70
  status [id] Registry + install map + drift (omit id = summary)
71
+ config <id> … Repo-layer config from core/config.schema.json (get|set|unset|rebuild|import|check|
72
+ validate|schema, <collection> list|add|remove, detect, init)
71
73
  sync-registry Register all local brain skills into the catalog
72
74
  unregister <id> Remove catalog entry (does not delete repo copies)
73
75
  track <path> Track any repo (full project brain + registry) for skill sync
@@ -131,6 +133,24 @@ function parseFlagValue(args, flag) {
131
133
 
132
134
  export default async function skillCli(args) {
133
135
  const { layer, remainingArgs } = parseLayerFlag(args);
136
+ if (remainingArgs[0] === 'config') {
137
+ // Repo-layer config is per repository; it needs no brain resolution.
138
+ const repoAt = remainingArgs.indexOf('--repo');
139
+ const repoRoot = repoAt === -1 ? process.cwd() : remainingArgs[repoAt + 1];
140
+ const rest = repoAt === -1 ? remainingArgs : remainingArgs.filter((_, i) => i !== repoAt && i !== repoAt + 1);
141
+ const skillId = rest[1];
142
+ if (!skillId || !repoRoot) {
143
+ console.error('❌ Usage: total-recall skill config <id> <config|detect|init|collection> … [--repo <path>] [--json]');
144
+ process.exitCode = 2;
145
+ return;
146
+ }
147
+ const { runSkillConfig } = await import('./skill-config.mjs');
148
+ const verbArgs = rest.slice(2);
149
+ // `skill config <id> get x` reads as the config verb; `skill config <id> detect` stays a verb.
150
+ const normalized = ['get', 'set', 'unset', 'validate', 'schema', 'rebuild', 'import', 'check'].includes(verbArgs[0]) ? ['config', ...verbArgs] : verbArgs;
151
+ await runSkillConfig(skillId, normalized, { repoRoot });
152
+ return;
153
+ }
134
154
  if (remainingArgs.length === 0 || remainingArgs.includes('--help') || remainingArgs.includes('-h')) {
135
155
  printHelp();
136
156
  return;
@@ -486,6 +506,9 @@ Provide a high-level explanation of the skill's capabilities and context.
486
506
  console.log(`\n ✅ Deployed: ${result.skillId}`);
487
507
  console.log(` → ${result.destDir}`);
488
508
  console.log(` hash=${result.install.content_hash} adapted=${result.adapt.adapted}`);
509
+ if (result.install.layered) {
510
+ console.log(` core=${result.install.core_hash} repo=${result.install.repo_hash}`);
511
+ }
489
512
  console.log(` Install map updated in ${resolveRegistryPath(brainDir)}\n`);
490
513
  } catch (err) {
491
514
  console.error(`❌ Deploy failed: ${err.message}`);
@@ -518,13 +541,25 @@ Provide a high-level explanation of the skill's capabilities and context.
518
541
  console.log(` Version: ${st.entry.version} hash=${st.entry.content_hash}`);
519
542
  console.log(` Source: ${st.entry.source_path || st.entry.source}`);
520
543
  }
521
- console.log(` Installs: ${st.install_count} drift=${st.any_drift ? 'YES' : 'no'}`);
544
+ console.log(` Installs: ${st.install_count} drift=${st.any_drift ? 'YES' : 'no'}${st.any_contract_failure ? ' contract=FAILED' : ''}`);
522
545
  for (const inst of st.installs) {
523
546
  console.log(
524
- ` • ${inst.path}\n exists=${inst.exists} live=${inst.live_hash || '-'} drift=${inst.drift}`,
547
+ ` • ${inst.path}\n exists=${inst.exists} live=${inst.live_hash || '-'} drift=${inst.drift}` +
548
+ (inst.adopted === false ? ' (legacy copy, not adopted: deploy to adopt)' : '') +
549
+ (inst.error ? `\n error=${inst.error}` : ''),
525
550
  );
551
+ if (inst.layered) {
552
+ console.log(` core=${inst.core_hash || '-'} repo=${inst.repo_hash || '-'} repo_changed=${inst.repo_changed}`);
553
+ }
554
+ if (inst.contract) {
555
+ console.log(` contract=${inst.contract.ok ? 'ok' : 'FAILED'}`);
556
+ for (const c of inst.contract.checks.filter((check) => !check.ok)) {
557
+ console.log(` ${c.level === 'warn' ? '⚠' : '✖'} ${c.message}`);
558
+ }
559
+ }
526
560
  }
527
561
  console.log('');
562
+ if (st.any_contract_failure) process.exitCode = 1;
528
563
  } catch (err) {
529
564
  console.error(`❌ ${err.message}`);
530
565
  process.exit(1);
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Route table of the Total Recall HTTP API, read from the Express routers
3
+ * themselves so a CLI verb exists for every route without a hand-kept list.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const ROUTES_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../server/routes');
10
+
11
+ /** Drop structured info log lines emitted by the modules the routes import, so CLI output stays parseable. */
12
+ export function silenceInfoLogs() {
13
+ for (const stream of [process.stdout, process.stderr]) {
14
+ const write = stream.write.bind(stream);
15
+ stream.write = (chunk, ...rest) =>
16
+ typeof chunk === 'string' && /^\{"timestamp":"[^"]+","level":"(info|debug)"/.test(chunk)
17
+ ? true
18
+ : write(chunk, ...rest);
19
+ }
20
+ }
21
+
22
+ function isRouter(x) {
23
+ return typeof x === 'function' && Array.isArray(x.stack);
24
+ }
25
+
26
+ /** Verb name for a route: the static path after /api/<group>, params dropped. */
27
+ export function actionFor(method, routePath, group) {
28
+ const segs = routePath.replace(/^\/api\//, '').split('/').filter(Boolean);
29
+ if (segs[0] === group) segs.shift();
30
+ const staticSegs = segs.filter((s) => !s.startsWith(':') && s !== '*');
31
+ const name = staticSegs.join('-');
32
+ if (name) return name;
33
+ return { GET: 'list', POST: 'create', PUT: 'update', PATCH: 'patch', DELETE: 'delete' }[method] || method.toLowerCase();
34
+ }
35
+
36
+ /**
37
+ * @returns {Promise<Array<{group:string, method:string, path:string, action:string, params:string[]}>>}
38
+ */
39
+ export async function loadApiRoutes() {
40
+ silenceInfoLogs();
41
+ const out = [];
42
+ const files = fs
43
+ .readdirSync(ROUTES_DIR)
44
+ .filter((f) => f.endsWith('.mjs') && !f.endsWith('.spec.mjs') && !f.startsWith('_'))
45
+ .sort();
46
+ for (const file of files) {
47
+ const group = file.replace(/\.mjs$/, '');
48
+ let mod;
49
+ // Route modules log startup lines on import; keep CLI output clean.
50
+ const { log, info } = console;
51
+ console.log = console.info = () => {};
52
+ try {
53
+ mod = await import(path.join(ROUTES_DIR, file));
54
+ } catch {
55
+ continue;
56
+ } finally {
57
+ console.log = log;
58
+ console.info = info;
59
+ }
60
+ const routers = Object.values(mod).filter(isRouter);
61
+ const seen = new Set();
62
+ for (const router of routers) {
63
+ for (const layer of router.stack) {
64
+ if (!layer.route) continue;
65
+ const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path];
66
+ for (const p of paths) {
67
+ if (typeof p !== 'string') continue;
68
+ for (const method of Object.keys(layer.route.methods).map((m) => m.toUpperCase())) {
69
+ const key = `${method} ${p}`;
70
+ if (seen.has(key)) continue;
71
+ seen.add(key);
72
+ out.push({
73
+ group,
74
+ method,
75
+ path: p,
76
+ action: actionFor(method, p, group),
77
+ params: [...p.matchAll(/:([A-Za-z0-9_]+)/g)].map((m) => m[1]),
78
+ });
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ // Make (group, action) unique: suffix the method on collisions.
85
+ const count = new Map();
86
+ for (const r of out) count.set(`${r.group}/${r.action}`, (count.get(`${r.group}/${r.action}`) || 0) + 1);
87
+ const used = new Map();
88
+ for (const r of out) {
89
+ const k = `${r.group}/${r.action}`;
90
+ if (count.get(k) > 1) {
91
+ let name = `${r.action}-${r.method.toLowerCase()}`;
92
+ const n = (used.get(`${r.group}/${name}`) || 0) + 1;
93
+ used.set(`${r.group}/${name}`, n);
94
+ if (n > 1) name = `${name}-${n}`;
95
+ r.action = name;
96
+ }
97
+ }
98
+ return out;
99
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Shared conformance boundary for app deployment adapters.
3
+ *
4
+ * An adapter owns the target runtime, but every adapter must prove the same
5
+ * lifecycle against an isolated app. This runner intentionally uses the SSSS
6
+ * package's canonical operation fixture rather than a Total Recall-only record.
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import crypto from 'node:crypto';
12
+
13
+ export const ADAPTER_CONTRACT_VERSION = '1.0.0';
14
+ export const ADAPTER_METHODS = Object.freeze([
15
+ 'detect',
16
+ 'generate',
17
+ 'executeSsss',
18
+ 'rebuildProjection',
19
+ 'start',
20
+ 'cleanup'
21
+ ]);
22
+
23
+ export function assertAdapterContract(adapter) {
24
+ if (!adapter || typeof adapter !== 'object' || !/^[a-z][a-z0-9-]*$/.test(adapter.id || '')) {
25
+ throw new TypeError('Adapter must have a safe, lowercase id');
26
+ }
27
+ for (const method of ADAPTER_METHODS) {
28
+ if (typeof adapter[method] !== 'function') {
29
+ throw new TypeError(`Adapter '${adapter.id}' must implement ${method}()`);
30
+ }
31
+ }
32
+ return adapter;
33
+ }
34
+
35
+ function fail(stage, message) {
36
+ throw new Error(`Adapter contract ${stage}: ${message}`);
37
+ }
38
+
39
+ function digest(file) {
40
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
41
+ }
42
+
43
+ function inside(parent, child) {
44
+ const relative = path.relative(parent, child);
45
+ return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
46
+ }
47
+
48
+ function snapshot(dir) {
49
+ const entries = [];
50
+ function walk(current, prefix = '') {
51
+ for (const item of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
52
+ const relative = path.posix.join(prefix, item.name);
53
+ const absolute = path.join(current, item.name);
54
+ const stat = fs.lstatSync(absolute);
55
+ if (stat.isSymbolicLink()) fail('generation', `symlink '${relative}' is not portable`);
56
+ if (stat.isDirectory()) walk(absolute, relative);
57
+ else if (stat.isFile()) entries.push([relative, digest(absolute)]);
58
+ else fail('generation', `unsupported file '${relative}'`);
59
+ }
60
+ }
61
+ walk(dir);
62
+ return entries;
63
+ }
64
+
65
+ /**
66
+ * Run the same lifecycle proof for each declared adapter. The caller supplies
67
+ * a canonical SSSS operation fixture with request and expected_response.
68
+ * Adapter methods receive an isolated target and never the operator's vault.
69
+ */
70
+ export async function runAdapterContractFixtures(adapter, operationFixture) {
71
+ assertAdapterContract(adapter);
72
+ if (!operationFixture?.request || operationFixture.expected_response?.success !== true) {
73
+ throw new TypeError('A successful canonical SSSS operation fixture is required');
74
+ }
75
+
76
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-adapter-contract-'));
77
+ const target = path.join(root, 'app');
78
+ const first = path.join(root, 'generated-a');
79
+ const second = path.join(root, 'generated-b');
80
+ const vault = path.join(target, 'vault');
81
+ const privateFile = path.join(vault, 'tenant-private.txt');
82
+ let runtime;
83
+ try {
84
+ fs.mkdirSync(vault, { recursive: true });
85
+ fs.mkdirSync(first);
86
+ fs.mkdirSync(second);
87
+ fs.writeFileSync(privateFile, 'private user data\n');
88
+ fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
89
+ name: 'adapter-contract-app',
90
+ version: '0.0.0',
91
+ type: 'module',
92
+ ssss: { adapter: adapter.id }
93
+ }));
94
+
95
+ const targetBefore = snapshot(target);
96
+ const detection = await adapter.detect(target);
97
+ if (detection?.supported !== true || detection.adapter !== adapter.id) {
98
+ fail('detection', `expected a compatible '${adapter.id}' target`);
99
+ }
100
+ if (JSON.stringify(snapshot(target)) !== JSON.stringify(targetBefore)) {
101
+ fail('detection', 'target detection changed the app');
102
+ }
103
+
104
+ await adapter.generate({ target, output: first });
105
+ await adapter.generate({ target, output: second });
106
+ const generated = snapshot(first);
107
+ if (generated.length === 0 || JSON.stringify(generated) !== JSON.stringify(snapshot(second))) {
108
+ fail('generation', 'source output must be nonempty and deterministic');
109
+ }
110
+ if (JSON.stringify(snapshot(target)) !== JSON.stringify(targetBefore)) {
111
+ fail('generation', 'planning/generation changed the target app');
112
+ }
113
+
114
+ const request = structuredClone(operationFixture.request);
115
+ const denied = await adapter.executeSsss(request, { target, principal: null });
116
+ if (denied?.success !== false) fail('kernel bridge', 'missing verified principal was accepted');
117
+
118
+ const accepted = await adapter.executeSsss(request, {
119
+ target,
120
+ principal: { id: 'fixture-admin', role: 'system', verified: true }
121
+ });
122
+ if (accepted?.success !== true || accepted?.validation?.valid !== true) {
123
+ fail('kernel bridge', `canonical operation failed: ${JSON.stringify(accepted?.validation?.errors || [])}`);
124
+ }
125
+ const document = path.join(vault, request.path);
126
+ if (!fs.existsSync(document)) fail('kernel bridge', 'canonical document was not committed to the app vault');
127
+ const canonicalHash = digest(document);
128
+ const replay = await adapter.executeSsss(request, {
129
+ target,
130
+ principal: { id: 'fixture-admin', role: 'system', verified: true }
131
+ });
132
+ if (replay?.success !== true || replay?.replay !== true || digest(document) !== canonicalHash) {
133
+ fail('kernel bridge', 'same operation did not replay idempotently');
134
+ }
135
+ const escape = { ...request, idempotency_key: `${request.idempotency_key}-escape`, path: '../outside.md' };
136
+ const escaped = await adapter.executeSsss(escape, {
137
+ target,
138
+ principal: { id: 'fixture-admin', role: 'system', verified: true }
139
+ });
140
+ if (escaped?.success !== false || fs.existsSync(path.join(target, 'outside.md'))) {
141
+ fail('kernel bridge', 'path escape was accepted');
142
+ }
143
+
144
+ const projection = await adapter.rebuildProjection({ target });
145
+ const projectionPath = projection?.path && path.resolve(projection.path);
146
+ if (!projectionPath || !inside(target, projectionPath) || inside(vault, projectionPath)
147
+ || !fs.existsSync(projectionPath) || !fs.lstatSync(projectionPath).isFile()
148
+ || !inside(fs.realpathSync(target), fs.realpathSync(projectionPath))) {
149
+ fail('projection', 'rebuild must produce a regular file inside the app and outside its vault');
150
+ }
151
+ const projectionHash = digest(projectionPath);
152
+ fs.unlinkSync(projectionPath);
153
+ await adapter.rebuildProjection({ target });
154
+ if (!fs.existsSync(projectionPath) || digest(projectionPath) !== projectionHash || digest(document) !== canonicalHash) {
155
+ fail('projection', 'rebuild is nondeterministic or changed canonical state');
156
+ }
157
+
158
+ runtime = await adapter.start({ target });
159
+ if (!runtime || typeof runtime.health !== 'function' || typeof runtime.stop !== 'function') {
160
+ fail('runtime', 'start must return health() and stop()');
161
+ }
162
+ const health = await runtime.health();
163
+ if (health?.status !== 'healthy') fail('runtime', 'health check did not report healthy');
164
+ await runtime.stop();
165
+ runtime = null;
166
+
167
+ await adapter.cleanup({ target, generated: [first, second] });
168
+ if (fs.readFileSync(privateFile, 'utf8') !== 'private user data\n' || digest(document) !== canonicalHash) {
169
+ fail('cleanup', 'tenant-private or canonical app data changed');
170
+ }
171
+ return { valid: true, adapter: adapter.id, contract_version: ADAPTER_CONTRACT_VERSION };
172
+ } finally {
173
+ if (runtime?.stop) await runtime.stop();
174
+ fs.rmSync(root, { recursive: true, force: true });
175
+ }
176
+ }