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
@@ -9,7 +9,11 @@ import { agentDir, brainDir } from './config.mjs';
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
10
  const ROOT = path.join(__dirname, '..', '..');
11
11
 
12
- const PID_FILE = path.join(brainDir, 'logs', 'daemon.pid');
12
+ // The daemon's own lockfile (daemon-loop acquirePidLock). start/stop/status read the
13
+ // same file: a separate logs/daemon.pid let `daemon stop` miss the live daemon and
14
+ // leave it running old code alongside a newly started one.
15
+ export const DAEMON_PID_FILE = path.join(brainDir, 'daemon.pid');
16
+ const PID_FILE = DAEMON_PID_FILE;
13
17
  const LOG_FILE = path.join(brainDir, 'logs', 'daemon.log');
14
18
 
15
19
  /**
@@ -104,8 +108,8 @@ export function startDaemon() {
104
108
 
105
109
  child.unref();
106
110
 
107
- // Write the PID file
108
- fs.writeFileSync(PID_FILE, String(child.pid), 'utf8');
111
+ // The daemon writes DAEMON_PID_FILE itself when it acquires its lock;
112
+ // pre-writing the child's pid here made the child see a live lock and exit.
109
113
  fs.closeSync(logFd);
110
114
 
111
115
  logger.info('daemon-control', `Active Intelligence Daemon started detached (PID ${child.pid}).`);
@@ -113,10 +117,13 @@ export function startDaemon() {
113
117
  }
114
118
 
115
119
  /**
116
- * Stops the running background daemon.
117
- * @returns {boolean} True if successfully stopped, false otherwise.
120
+ * Stops the running background daemon and waits for the process to exit.
121
+ * The daemon owns its lockfile and removes it on exit, so a start issued right
122
+ * after stop can never run alongside a daemon that is still finishing work.
123
+ * @param {{ graceMs?: number, pollMs?: number }} [opts]
124
+ * @returns {Promise<boolean>} True if a daemon was running and has exited.
118
125
  */
119
- export function stopDaemon() {
126
+ export async function stopDaemon({ graceMs = 35_000, pollMs = 250 } = {}) {
120
127
  const pid = readPid();
121
128
  if (!pid) {
122
129
  // Clear PID file in case it was a stale/dead record
@@ -126,19 +133,31 @@ export function stopDaemon() {
126
133
  return false;
127
134
  }
128
135
 
136
+ const alive = () => { try { process.kill(pid, 0); return true; } catch { return false; } };
129
137
  try {
130
138
  process.kill(pid, 'SIGTERM');
131
139
  logger.info('daemon-control', `Sent SIGTERM to daemon PID ${pid}.`);
132
-
133
- // Wait briefly and verify cleanup
134
- try {
135
- if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
136
- } catch {}
137
- return true;
138
140
  } catch (err) {
139
141
  logger.error('daemon-control', `Failed to terminate daemon PID ${pid}: ${err.message}`);
140
142
  return false;
141
143
  }
144
+
145
+ const deadline = Date.now() + graceMs;
146
+ while (alive() && Date.now() < deadline) {
147
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
148
+ }
149
+ if (alive()) {
150
+ logger.warn('daemon-control', `Daemon PID ${pid} still running after ${graceMs}ms — sending SIGKILL.`);
151
+ try { process.kill(pid, 'SIGKILL'); } catch {}
152
+ }
153
+
154
+ // A SIGKILLed daemon cannot run its exit handler; drop its lock if still ours.
155
+ try {
156
+ if (fs.existsSync(PID_FILE) && parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10) === pid) {
157
+ fs.unlinkSync(PID_FILE);
158
+ }
159
+ } catch {}
160
+ return true;
142
161
  }
143
162
 
144
163
  /**
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import path from 'path';
15
+ import { DAEMON_PID_FILE } from './daemon-control.mjs';
15
16
  import fs from 'fs';
16
17
  import { fileURLToPath } from 'node:url';
17
18
  import { readProcessCommand, entryPathHint, shouldHonorPidLock } from './pid-lock.mjs';
@@ -102,7 +103,7 @@ export function writeInterrupt(message) {
102
103
 
103
104
  // ─── PID Lockfile ───────────────────────────────────────────────────────────────
104
105
 
105
- const PID_FILE = path.join(BRAIN_DIR, 'daemon.pid');
106
+ const PID_FILE = DAEMON_PID_FILE;
106
107
 
107
108
  /**
108
109
  * Attempt to acquire the PID lockfile. If another daemon is alive, exit.
@@ -112,7 +113,7 @@ export function acquirePidLock() {
112
113
  try {
113
114
  if (fs.existsSync(PID_FILE)) {
114
115
  const existingPid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
115
- if (existingPid && !isNaN(existingPid)) {
116
+ if (existingPid && !isNaN(existingPid) && existingPid !== process.pid) {
116
117
  // Liveness alone cannot tell our daemon from whatever else inherited
117
118
  // the number after a reboot — observed here holding a PID that had
118
119
  // since been handed to an unrelated desktop app, which would refuse
@@ -218,14 +219,20 @@ function shutdown(signal) {
218
219
  });
219
220
  })
220
221
  .finally(() => {
221
- try {
222
- releasePidLock();
223
- } catch {
224
- // Lock already gone — nothing to release.
225
- }
222
+ // Keep the PID lock until the process really exits (see the 'exit'
223
+ // handler): releasing it here let a new daemon start while this one was
224
+ // still finishing its current task.
226
225
  });
227
226
  }
228
227
 
228
+ process.on('exit', () => {
229
+ try {
230
+ releasePidLock();
231
+ } catch {
232
+ // Lock already gone — nothing to release.
233
+ }
234
+ });
235
+
229
236
  process.on('SIGTERM', () => shutdown('SIGTERM'));
230
237
  process.on('SIGINT', () => shutdown('SIGINT'));
231
238
 
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { validateAppCliSpec } from './app-deploy/runtime-cli.mjs';
6
+ import { validateUiSpec } from './app-deploy/ui-elements.mjs';
5
7
 
6
8
  const ID_PATTERN = /^[a-z][a-z0-9-]{1,63}$/;
7
9
  const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
@@ -226,27 +228,25 @@ export function validatePluginManifest(manifest) {
226
228
  errors.push("Each entry in 'commands' must be an object");
227
229
  continue;
228
230
  }
229
- if (!c.name || typeof c.name !== 'string') {
230
- errors.push("Each command entry must have a string 'name'");
231
+ if (typeof c.name !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(c.name)) {
232
+ errors.push("Each command entry must have a safe lowercase kebab-case 'name'");
231
233
  }
232
234
  if (!c.handler || !isSafeRelativePath(c.handler)) {
233
235
  errors.push(`Command '${c.name || 'unknown'}' has invalid or unsafe 'handler': must be safe relative path`);
234
236
  }
237
+ if (c.background !== undefined && typeof c.background !== 'boolean') {
238
+ errors.push(`Command '${c.name || 'unknown'}' background must be boolean`);
239
+ }
235
240
  }
236
241
  }
237
242
  }
238
243
 
239
244
  if (manifest.ui !== undefined) {
240
- if (typeof manifest.ui !== 'object' || manifest.ui === null) {
241
- errors.push("'ui' must be an object");
242
- } else {
243
- if (manifest.ui.design_tokens !== undefined && !isSafeRelativePath(manifest.ui.design_tokens)) {
244
- errors.push("'ui.design_tokens' must be a safe relative path");
245
- }
246
- if (manifest.ui.elements !== undefined && !Array.isArray(manifest.ui.elements)) {
247
- errors.push("'ui.elements' must be an array");
248
- }
249
- }
245
+ errors.push(...validateUiSpec(manifest.ui));
246
+ }
247
+
248
+ if (manifest.app_cli !== undefined) {
249
+ errors.push(...validateAppCliSpec(manifest.app_cli));
250
250
  }
251
251
 
252
252
  if (manifest.openwiki_hubs !== undefined) {
@@ -231,6 +231,63 @@ export const ConversationSchema = z.object({
231
231
  created_at: ssssDatetime().optional(),
232
232
  });
233
233
 
234
+ // SSSS 0.10 core primitives (spec §5.4). `role` and `thread` are the canonical
235
+ // forms of `security_role` and `conversation`, which stay for existing vaults.
236
+ export const AssetSchema = z.object({
237
+ type: z.literal('asset'),
238
+ name: z.string(),
239
+ mime_type: z.string(),
240
+ encoding: z.enum(['base64', 'utf-8', 'none']),
241
+ slug: z.string().optional(),
242
+ hash: z.string().optional(),
243
+ size_bytes: z.number().int().nonnegative().optional(),
244
+ description: z.string().optional(),
245
+ });
246
+
247
+ export const ResourceSchema = z.object({
248
+ type: z.literal('resource'),
249
+ name: z.string(),
250
+ kind: z.string(),
251
+ status: z.enum(['bound', 'unbound', 'pending', 'error']),
252
+ slug: z.string().optional(),
253
+ description: z.string().optional(),
254
+ binds: z.unknown().optional(),
255
+ provisioning: z.unknown().optional(),
256
+ });
257
+
258
+ export const RoleSchema = z.object({
259
+ type: z.literal('role'),
260
+ name: z.string(),
261
+ permissions: z.array(z.string()),
262
+ description: z.string().optional(),
263
+ });
264
+
265
+ export const TriggerSchema = z.object({
266
+ type: z.literal('trigger'),
267
+ name: z.string(),
268
+ source: z.enum(['cron', 'interval', 'webhook', 'event', 'file_change', 'condition', 'manual']),
269
+ status: z.enum(['active', 'paused', 'disabled']),
270
+ slug: z.string().optional(),
271
+ description: z.string().optional(),
272
+ cron: z.string().optional(),
273
+ interval: z.union([z.string(), z.number()]).optional(),
274
+ timezone: z.string().optional(),
275
+ target_workflow: z.string().optional(),
276
+ target_params: z.record(z.unknown()).optional(),
277
+ concurrency: z.enum(['allow_parallel', 'skip_if_running', 'enqueue', 'replace']).optional(),
278
+ misfire_policy: z.enum(['skip', 'run_once', 'catch_up']).optional(),
279
+ });
280
+
281
+ export const ThreadSchema = z.object({
282
+ type: z.literal('thread'),
283
+ thread_id: z.string(),
284
+ workspace_id: z.string().optional(),
285
+ user_id: z.string().optional(),
286
+ status: z.enum(['active', 'archived', 'closed']).optional(),
287
+ turn_count: z.number().int().optional(),
288
+ created_at: ssssDatetime().optional(),
289
+ });
290
+
234
291
  export const RunSchema = z.object({
235
292
  type: z.literal('run'),
236
293
  run_id: z.string(),
@@ -1162,25 +1219,22 @@ export const AccessGrantSchema = z.object({
1162
1219
  }).passthrough();
1163
1220
 
1164
1221
  /**
1165
- * Canonical repo-layer skill configuration record.
1166
- * Persisted as an SSSS document in the project vault (e.g. `system/skills/<id>.md`).
1167
- * Controls gate commands, tier rules, and repo customizations.
1168
- * Projected into `config.json` for skill scripts to read.
1222
+ * Canonical repo-layer configuration for a layered skill, stored in the repo's
1223
+ * project vault at `system/skills/<skill_id>.md` and written only through the
1224
+ * operation service (`src/core/skill-config.mjs`). The shape of `config` is the
1225
+ * skill core's contract (`core/config.schema.json`), not this host type, so no
1226
+ * skill-specific fields belong here. `config.json` beside the skill is a
1227
+ * projection of `config`, rebuilt after every write.
1169
1228
  */
1170
1229
  export const SkillConfigSchema = z.object({
1171
1230
  type: z.literal('skill_config'),
1172
1231
  title: z.string().min(1),
1173
1232
  description: z.string().min(1),
1174
1233
  timestamp: ssssDatetime(),
1175
- skill_id: z.string().regex(/^[a-z][a-z0-9-]{1,63}$/),
1176
- version: z.string().min(1),
1177
- config: z.record(z.any()).default({}),
1178
- tiers: z.record(z.any()).optional().default({}),
1179
- gates: z.array(z.object({
1180
- name: z.string(),
1181
- command: z.string(),
1182
- tier: z.string().optional()
1183
- })).optional().default([]),
1234
+ skill_id: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/),
1235
+ config: z.record(z.string(), z.any()),
1236
+ /** SHA-256 of the core/config.schema.json the config was validated against. */
1237
+ schema_sha256: z.string().regex(/^[0-9a-f]{64}$/),
1184
1238
  created_at: ssssDatetime(),
1185
1239
  updated_at: ssssDatetime().optional()
1186
1240
  }).passthrough();
@@ -1221,6 +1275,11 @@ export const SSSS_SCHEMAS = {
1221
1275
  security_role: SecurityRoleSchema,
1222
1276
  model: ModelSchema,
1223
1277
  conversation: ConversationSchema,
1278
+ asset: AssetSchema,
1279
+ resource: ResourceSchema,
1280
+ role: RoleSchema,
1281
+ trigger: TriggerSchema,
1282
+ thread: ThreadSchema,
1224
1283
  run: RunSchema,
1225
1284
  primitive: PrimitiveSchema,
1226
1285
  proposal: ProposalSchema,
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Repo-layer configuration for layered skills.
3
+ *
4
+ * A layered skill's plugin-owned core ships the layer contract as
5
+ * `core/config.schema.json` (JSON Schema, root type object) and may ship
6
+ * `core/detect.mjs`. The canonical repo config is a `skill_config` SSSS
7
+ * document in the repo's project vault (`system/skills/<id>.md`), written
8
+ * through the operation service after the whole config validates against the
9
+ * contract. `config.json` next to `core/` is a projection of that record for
10
+ * the core scripts to read; it is rebuilt after every write.
11
+ *
12
+ * Collections: an array property annotated with `"x-collection": "<noun>"`
13
+ * gets `<noun> list|add|remove` verbs; items are keyed by `"x-key"` (default
14
+ * `name`).
15
+ */
16
+ import crypto from 'node:crypto';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { isDeepStrictEqual } from 'node:util';
20
+ import { pathToFileURL } from 'node:url';
21
+ import { z } from 'zod';
22
+ import { writeVfsDocument } from './ssss-operation-service.mjs';
23
+ import { findVfsDocumentByPath } from './vfs-documents.mjs';
24
+
25
+ const SKILL_ID = /^[a-z][a-z0-9-]{0,63}$/;
26
+ const FORBIDDEN_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
27
+
28
+ export class SkillConfigError extends Error {
29
+ constructor(message, { exitCode = 2, issues = [] } = {}) {
30
+ super(message);
31
+ this.exitCode = exitCode;
32
+ this.issues = issues;
33
+ }
34
+ }
35
+
36
+ function regularFile(file) {
37
+ return fs.existsSync(file) && !fs.lstatSync(file).isSymbolicLink() && fs.statSync(file).isFile();
38
+ }
39
+
40
+ /** Locate a deployed layered skill inside a repository. */
41
+ export function resolveSkillDir(repoRoot, skillId) {
42
+ if (!SKILL_ID.test(skillId || '')) throw new SkillConfigError('Skill id must be lowercase kebab-case');
43
+ const skillDir = path.join(path.resolve(repoRoot), '.agent', 'skills', skillId);
44
+ if (!fs.existsSync(skillDir)) throw new SkillConfigError(`Skill '${skillId}' is not deployed in ${repoRoot}`, { exitCode: 1 });
45
+ if (fs.lstatSync(skillDir).isSymbolicLink()) throw new SkillConfigError(`Refusing symlinked skill directory: ${skillDir}`, { exitCode: 1 });
46
+ return skillDir;
47
+ }
48
+
49
+ /** Load and compile the core-owned layer contract. */
50
+ export function loadConfigSchema(skillDir) {
51
+ const coreDir = path.join(skillDir, 'core');
52
+ if (fs.existsSync(coreDir) && fs.lstatSync(coreDir).isSymbolicLink()) {
53
+ throw new SkillConfigError(`Refusing symlinked skill core: ${coreDir}`, { exitCode: 1 });
54
+ }
55
+ const schemaPath = path.join(coreDir, 'config.schema.json');
56
+ if (!regularFile(schemaPath)) {
57
+ throw new SkillConfigError(`Skill has no config contract (expected core/config.schema.json)`, { exitCode: 1 });
58
+ }
59
+ const raw = fs.readFileSync(schemaPath);
60
+ let schema;
61
+ try { schema = JSON.parse(raw.toString('utf8')); }
62
+ catch (error) { throw new SkillConfigError(`core/config.schema.json is not valid JSON: ${error.message}`, { exitCode: 1 }); }
63
+ if (schema?.type !== 'object') throw new SkillConfigError('core/config.schema.json must describe an object', { exitCode: 1 });
64
+ let validator;
65
+ try { validator = z.fromJSONSchema(schema); }
66
+ catch (error) { throw new SkillConfigError(`core/config.schema.json is not a supported JSON Schema: ${error.message}`, { exitCode: 1 }); }
67
+ return { schema, validator, schemaPath, sha256: crypto.createHash('sha256').update(raw).digest('hex') };
68
+ }
69
+
70
+ function formatIssues(issues) {
71
+ return issues.map((issue) => `${issue.path.length ? issue.path.join('.') : '(root)'}: ${issue.message}`);
72
+ }
73
+
74
+ /** Validate a whole config document; returns the normalized value (defaults applied). */
75
+ export function validateSkillConfig(contract, value) {
76
+ const result = contract.validator.safeParse(value);
77
+ if (result.success) return { valid: true, value: result.data, errors: [] };
78
+ return { valid: false, value: null, errors: formatIssues(result.error.issues) };
79
+ }
80
+
81
+ export function configPath(skillDir) {
82
+ return path.join(skillDir, 'config.json');
83
+ }
84
+
85
+ export function recordPath(skillId) {
86
+ return `system/skills/${skillId}.md`;
87
+ }
88
+
89
+ /** The repo's own project vault. Never walks up into a parent project's brain. */
90
+ export function projectVaultFor(skillDir) {
91
+ const repoRoot = path.resolve(skillDir, '..', '..', '..');
92
+ const brain = path.join(repoRoot, '.agent', 'skills', 'total-recall');
93
+ if (!regularFile(path.join(brain, 'SKILL.md'))) {
94
+ throw new SkillConfigError(`No project brain in ${repoRoot}; run 'total-recall init --project' there first`, { exitCode: 1 });
95
+ }
96
+ return path.join(brain, 'memory-vault');
97
+ }
98
+
99
+ function readProjection(skillDir) {
100
+ const file = configPath(skillDir);
101
+ if (!fs.existsSync(file)) return null;
102
+ if (!regularFile(file)) throw new SkillConfigError(`Refusing non-regular config file: ${file}`, { exitCode: 1 });
103
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
104
+ catch (error) { throw new SkillConfigError(`config.json is not valid JSON: ${error.message}`, { exitCode: 1 }); }
105
+ }
106
+
107
+ /**
108
+ * Current repo config. `source` is 'record' (canonical), 'projection' (a
109
+ * config.json with no record yet, e.g. a repo skill awaiting import), or null.
110
+ */
111
+ export function readSkillConfigState(skillDir) {
112
+ const skillId = path.basename(skillDir);
113
+ const record = findVfsDocumentByPath(recordPath(skillId), projectVaultFor(skillDir));
114
+ const projection = readProjection(skillDir);
115
+ if (record) {
116
+ if (record.type !== 'skill_config' || !record.config || typeof record.config !== 'object') {
117
+ throw new SkillConfigError(`${recordPath(skillId)} is not a skill_config record`, { exitCode: 1 });
118
+ }
119
+ return { config: record.config, source: 'record', record, projection, inSync: isDeepStrictEqual(projection, record.config) };
120
+ }
121
+ return { config: projection, source: projection === null ? null : 'projection', record: null, projection, inSync: projection === null };
122
+ }
123
+
124
+ export function readSkillConfig(skillDir) {
125
+ return readSkillConfigState(skillDir).config;
126
+ }
127
+
128
+ /** Rebuild config.json from the canonical record (atomic replace). */
129
+ export function projectSkillConfig(skillDir, config) {
130
+ const file = configPath(skillDir);
131
+ if (fs.existsSync(file) && !regularFile(file)) throw new SkillConfigError(`Refusing non-regular config file: ${file}`, { exitCode: 1 });
132
+ const temp = path.join(skillDir, `.config.json.${process.pid}.${Date.now()}.tmp`);
133
+ fs.writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
134
+ try { fs.renameSync(temp, file); }
135
+ catch (error) { fs.rmSync(temp, { force: true }); throw error; }
136
+ }
137
+
138
+ /**
139
+ * The single write path for repo-layer config: validate against the core
140
+ * contract, commit the skill_config record through the SSSS operation service,
141
+ * then rebuild the config.json projection.
142
+ */
143
+ export async function writeSkillConfig(skillDir, contract, value) {
144
+ const checked = validateSkillConfig(contract, value);
145
+ if (!checked.valid) {
146
+ throw new SkillConfigError(`Config does not satisfy core/config.schema.json:\n ${checked.errors.join('\n ')}`, { issues: checked.errors });
147
+ }
148
+ const file = configPath(skillDir);
149
+ if (fs.existsSync(file) && !regularFile(file)) throw new SkillConfigError(`Refusing non-regular config file: ${file}`, { exitCode: 1 });
150
+ const skillId = path.basename(skillDir);
151
+ const vaultRoot = projectVaultFor(skillDir);
152
+ const existing = findVfsDocumentByPath(recordPath(skillId), vaultRoot);
153
+ const now = new Date().toISOString();
154
+ const createdAt = existing?.created_at ? new Date(existing.created_at).toISOString() : now;
155
+ await writeVfsDocument(recordPath(skillId), {
156
+ type: 'skill_config',
157
+ title: `Skill config: ${skillId}`,
158
+ description: `Repo-layer configuration for the ${skillId} skill.`,
159
+ timestamp: now,
160
+ skill_id: skillId,
161
+ config: checked.value,
162
+ schema_sha256: contract.sha256,
163
+ created_at: createdAt,
164
+ ...(existing ? { updated_at: now } : {}),
165
+ }, '', { actorRole: 'system', intent: `Update repo config for skill ${skillId}`, vaultRoot });
166
+ projectSkillConfig(skillDir, checked.value);
167
+ return checked.value;
168
+ }
169
+
170
+ function splitPath(dotted) {
171
+ if (!dotted) return [];
172
+ const segments = String(dotted).split('.');
173
+ for (const segment of segments) {
174
+ if (!segment || FORBIDDEN_SEGMENTS.has(segment)) throw new SkillConfigError(`Invalid config path '${dotted}'`);
175
+ }
176
+ return segments;
177
+ }
178
+
179
+ export function getConfigValue(config, dotted) {
180
+ let node = config;
181
+ for (const segment of splitPath(dotted)) {
182
+ if (node === null || typeof node !== 'object' || !Object.hasOwn(node, segment)) return undefined;
183
+ node = node[segment];
184
+ }
185
+ return node;
186
+ }
187
+
188
+ /** Return a copy of `config` with `dotted` set (or removed when `value` is undefined). */
189
+ export function setConfigValue(config, dotted, value) {
190
+ const segments = splitPath(dotted);
191
+ if (segments.length === 0) throw new SkillConfigError('A config path is required');
192
+ const root = structuredClone(config ?? {});
193
+ let node = root;
194
+ for (const segment of segments.slice(0, -1)) {
195
+ if (node[segment] === undefined) node[segment] = {};
196
+ if (node[segment] === null || typeof node[segment] !== 'object') {
197
+ throw new SkillConfigError(`Config path '${dotted}' crosses a non-object value at '${segment}'`);
198
+ }
199
+ node = node[segment];
200
+ }
201
+ const last = segments.at(-1);
202
+ if (value === undefined) delete node[last];
203
+ else node[last] = value;
204
+ return root;
205
+ }
206
+
207
+ /** CLI values: JSON when it parses (numbers, booleans, arrays, objects), otherwise a string. */
208
+ export function parseCliValue(raw) {
209
+ try { return JSON.parse(raw); } catch { return raw; }
210
+ }
211
+
212
+ /** Array properties exposed as collection verbs, keyed by noun. */
213
+ export function listCollections(schema) {
214
+ const collections = {};
215
+ for (const [property, definition] of Object.entries(schema.properties || {})) {
216
+ const noun = definition?.['x-collection'];
217
+ if (definition?.type === 'array' && typeof noun === 'string' && SKILL_ID.test(noun)) {
218
+ collections[noun] = { property, key: definition['x-key'] || 'name' };
219
+ }
220
+ }
221
+ return collections;
222
+ }
223
+
224
+ /** Parse `key=value` pairs (or one JSON object) into a collection item. */
225
+ export function parseCollectionItem(args) {
226
+ if (args.length === 1 && args[0].trim().startsWith('{')) {
227
+ const item = parseCliValue(args[0]);
228
+ if (item && typeof item === 'object' && !Array.isArray(item)) return item;
229
+ throw new SkillConfigError('Collection item must be a JSON object');
230
+ }
231
+ if (args.length === 0) throw new SkillConfigError('Collection item needs key=value fields or a JSON object');
232
+ let item = {};
233
+ for (const arg of args) {
234
+ const at = arg.indexOf('=');
235
+ if (at <= 0) throw new SkillConfigError(`Expected key=value, got '${arg}'`);
236
+ item = setConfigValue(item, arg.slice(0, at), parseCliValue(arg.slice(at + 1)));
237
+ }
238
+ return item;
239
+ }
240
+
241
+ export function addCollectionItem(config, collection, item) {
242
+ const current = getConfigValue(config, collection.property) ?? [];
243
+ if (!Array.isArray(current)) throw new SkillConfigError(`'${collection.property}' is not an array`);
244
+ const key = item?.[collection.key];
245
+ if (key === undefined || key === '') throw new SkillConfigError(`Item needs a '${collection.key}' field`);
246
+ if (current.some((entry) => entry?.[collection.key] === key)) {
247
+ throw new SkillConfigError(`${collection.property} already has ${collection.key} '${key}'`);
248
+ }
249
+ return setConfigValue(config, collection.property, [...current, item]);
250
+ }
251
+
252
+ export function removeCollectionItem(config, collection, key) {
253
+ const current = getConfigValue(config, collection.property) ?? [];
254
+ if (!Array.isArray(current)) throw new SkillConfigError(`'${collection.property}' is not an array`);
255
+ const next = current.filter((entry) => String(entry?.[collection.key]) !== String(key));
256
+ if (next.length === current.length) throw new SkillConfigError(`${collection.property} has no ${collection.key} '${key}'`, { exitCode: 1 });
257
+ return setConfigValue(config, collection.property, next);
258
+ }
259
+
260
+ /** Run the core's detector. It proposes a config; nothing is written here. */
261
+ export async function detectSkillConfig(skillDir, repoRoot) {
262
+ const detector = path.join(skillDir, 'core', 'detect.mjs');
263
+ if (!regularFile(detector)) throw new SkillConfigError('Skill core has no detector (expected core/detect.mjs)', { exitCode: 1 });
264
+ const mod = await import(`${pathToFileURL(detector).href}?t=${fs.statSync(detector).mtimeMs}`);
265
+ const detect = mod.detect || mod.default;
266
+ if (typeof detect !== 'function') throw new SkillConfigError('core/detect.mjs must export detect()', { exitCode: 1 });
267
+ const proposal = await detect({ repoRoot: path.resolve(repoRoot), skillDir });
268
+ if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal)) {
269
+ throw new SkillConfigError('detect() must return a config object', { exitCode: 1 });
270
+ }
271
+ return proposal;
272
+ }
273
+
274
+ /** Required top-level properties still absent from `config`. */
275
+ export function missingRequired(schema, config) {
276
+ return (schema.required || []).filter((key) => config?.[key] === undefined);
277
+ }
278
+
279
+ /**
280
+ * Values at schema locations annotated `"x-command": true`, with their config
281
+ * paths. A command is a shell-style string or an argv array of strings.
282
+ */
283
+ export function collectCommandValues(schema, value, at = []) {
284
+ if (!schema || value === undefined || value === null) return [];
285
+ if (schema['x-command'] === true && (typeof value === 'string'
286
+ || (Array.isArray(value) && value.length && value.every((part) => typeof part === 'string')))) {
287
+ return [{ path: at.join('.'), command: value }];
288
+ }
289
+ const found = [];
290
+ if (schema.type === 'array' && Array.isArray(value) && schema.items) {
291
+ value.forEach((item, i) => found.push(...collectCommandValues(schema.items, item, [...at, String(i)])));
292
+ }
293
+ if (schema.properties && typeof value === 'object' && !Array.isArray(value)) {
294
+ for (const [key, child] of Object.entries(schema.properties)) {
295
+ found.push(...collectCommandValues(child, value[key], [...at, key]));
296
+ }
297
+ }
298
+ return found;
299
+ }
300
+
301
+ function onPath(binary) {
302
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
303
+ if (!dir) continue;
304
+ const candidate = path.join(dir, binary);
305
+ try {
306
+ if (fs.statSync(candidate).isFile()) { fs.accessSync(candidate, fs.constants.X_OK); return true; }
307
+ } catch { /* keep looking */ }
308
+ }
309
+ return false;
310
+ }
311
+
312
+ /** Whether a gate command's program exists; does not run it. */
313
+ export function resolveCommand(command, repoRoot) {
314
+ const tokens = Array.isArray(command) ? [...command] : String(command).trim().split(/\s+/).filter(Boolean);
315
+ while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
316
+ const program = tokens[0];
317
+ if (!program) return { ok: false, reason: 'empty command' };
318
+ if (path.isAbsolute(program)) {
319
+ return fs.existsSync(program) ? { ok: true } : { ok: false, reason: `${program} does not exist` };
320
+ }
321
+ if (program.includes('/')) {
322
+ const file = path.resolve(repoRoot, program);
323
+ const inside = !path.relative(repoRoot, file).startsWith('..');
324
+ return inside && fs.existsSync(file) ? { ok: true } : { ok: false, reason: `${program} does not exist in the repo` };
325
+ }
326
+ if (['npm', 'pnpm', 'yarn', 'bun'].includes(program) && tokens[1] === 'run' && tokens[2]) {
327
+ let scripts = {};
328
+ try { scripts = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).scripts || {}; } catch { /* no package.json */ }
329
+ return Object.hasOwn(scripts, tokens[2]) ? { ok: true } : { ok: false, reason: `package.json has no '${tokens[2]}' script` };
330
+ }
331
+ return onPath(program) ? { ok: true } : { ok: false, reason: `'${program}' is not on PATH` };
332
+ }
333
+
334
+ /**
335
+ * Check a deployed layered skill's repo layer against its core contract.
336
+ * Shared by `skill status`, `skill config <id> check`, and `app verify`.
337
+ * Each check is { id, level: 'error'|'warn', ok, message }; `ok` is false when
338
+ * any error-level check fails.
339
+ */
340
+ export function checkSkillLayerContract(skillDir) {
341
+ const skillId = path.basename(skillDir);
342
+ const repoRoot = path.resolve(skillDir, '..', '..', '..');
343
+ const checks = [];
344
+ const add = (id, ok, message, level = 'error') => checks.push({ id, level, ok, message });
345
+ const result = () => ({ skill_id: skillId, path: skillDir, ok: checks.every((c) => c.ok || c.level !== 'error'), checks });
346
+
347
+ let contract;
348
+ try { contract = loadConfigSchema(skillDir); add('contract', true, 'core/config.schema.json loads'); }
349
+ catch (error) { add('contract', false, error.message); return result(); }
350
+
351
+ let state;
352
+ try { state = readSkillConfigState(skillDir); }
353
+ catch (error) { add('record', false, error.message); return result(); }
354
+ if (state.source === null) { add('record', false, `Not configured; run 'total-recall skill config ${skillId} init'`); return result(); }
355
+ if (state.source === 'projection') {
356
+ add('record', false, `config.json is not recorded in SSSS; run 'total-recall skill config ${skillId} import'`);
357
+ } else {
358
+ add('record', true, `Recorded at ${recordPath(skillId)}`);
359
+ }
360
+
361
+ const checked = validateSkillConfig(contract, state.config);
362
+ add('config', checked.valid, checked.valid ? 'Config satisfies the contract' : `Config violates the contract: ${checked.errors.join('; ')}`);
363
+ if (state.source === 'record') {
364
+ const current = state.record.schema_sha256 === contract.sha256;
365
+ add('contract-version', current, current ? 'Validated against the current core contract'
366
+ : 'The core contract changed since this config was recorded; re-save it to re-validate', 'warn');
367
+ add('projection', state.inSync, state.inSync ? 'config.json matches the record'
368
+ : `config.json has drifted from the record; run 'total-recall skill config ${skillId} rebuild'`);
369
+ }
370
+ for (const { path: at, command } of collectCommandValues(contract.schema, state.config)) {
371
+ const resolved = resolveCommand(command, repoRoot);
372
+ const shown = Array.isArray(command) ? command.join(' ') : command;
373
+ add(`command:${at}`, resolved.ok, resolved.ok ? `${at}: '${shown}' resolves` : `${at}: ${resolved.reason}`);
374
+ }
375
+ return result();
376
+ }