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.
- package/bin/total-recall.mjs +2 -2
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
- package/src/cli/app/index.mjs +181 -1
- package/src/cli/command.mjs +269 -5
- package/src/cli/connect.mjs +1 -1
- package/src/cli/daemon.mjs +3 -3
- package/src/cli/init.mjs +54 -0
- package/src/cli/plugin/create.mjs +20 -10
- package/src/cli/repo-expert-generate.mjs +37 -3
- package/src/cli/skill-config.mjs +211 -0
- package/src/cli/skill.mjs +37 -2
- package/src/core/app-deploy/adapter-contract.mjs +176 -0
- package/src/core/app-deploy/design-tokens.mjs +148 -108
- package/src/core/app-deploy/plan.mjs +2 -2
- package/src/core/app-deploy/resolve.mjs +30 -2
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
- package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
- package/src/core/app-deploy/runtime-cli.mjs +254 -0
- package/src/core/app-deploy/ui-elements.mjs +473 -0
- package/src/core/app-deploy/verify.mjs +18 -0
- package/src/core/command-surface.mjs +129 -0
- package/src/core/daemon-control.mjs +31 -12
- package/src/core/daemon-loop.mjs +14 -7
- package/src/core/plugin-loader.mjs +12 -12
- package/src/core/schema.mjs +72 -13
- package/src/core/skill-config.mjs +376 -0
- package/src/core/skills-registry.mjs +147 -31
- package/src/core/surface.mjs +52 -7
- package/src/server/routes/system.mjs +1 -1
- package/plugins/code-quality/README.md +0 -25
- package/plugins/code-quality/cli.mjs +0 -25
- 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
|
-
|
|
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
|
-
//
|
|
108
|
-
|
|
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
|
-
*
|
|
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
|
/**
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 (
|
|
230
|
-
errors.push("Each command entry must have a
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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) {
|
package/src/core/schema.mjs
CHANGED
|
@@ -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
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
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-]{
|
|
1176
|
-
|
|
1177
|
-
config
|
|
1178
|
-
|
|
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
|
+
}
|