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
@@ -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) {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Stable per-project identity: a random UUID kept in the project brain's
3
+ * config/brain.json (`project_id`) and mirrored into the project registry.
4
+ * Never derived from names or paths.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
9
+
10
+ function brainJsonPath(brainDir) {
11
+ return path.join(brainDir, 'config', 'brain.json');
12
+ }
13
+
14
+ function readJson(file, fallback) {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
17
+ } catch {
18
+ return fallback;
19
+ }
20
+ }
21
+
22
+ function writeJsonAtomic(file, data) {
23
+ fs.mkdirSync(path.dirname(file), { recursive: true });
24
+ const tmp = `${file}.tmp-${process.pid}`;
25
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
26
+ fs.renameSync(tmp, file);
27
+ }
28
+
29
+ /** The project id stored for a project brain, or null. */
30
+ export function readProjectId(brainDir) {
31
+ return readJson(brainJsonPath(brainDir), {}).project_id || null;
32
+ }
33
+
34
+ /**
35
+ * Return the project's id, creating a random UUID on first use.
36
+ * @returns {{ project_id: string, created: boolean }}
37
+ */
38
+ export function ensureProjectId(brainDir) {
39
+ const file = brainJsonPath(brainDir);
40
+ const cfg = readJson(file, {});
41
+ if (cfg.project_id) return { project_id: cfg.project_id, created: false };
42
+ cfg.project_id = randomUUID();
43
+ writeJsonAtomic(file, cfg);
44
+ return { project_id: cfg.project_id, created: true };
45
+ }
46
+
47
+ /**
48
+ * Give every registered project an id and make the registry agree with each
49
+ * project's brain.json (the brain.json id wins).
50
+ * @returns {{ total: number, created: number, synced: number, missing_brain: number }}
51
+ */
52
+ export function repairProjectIds(globalBrainDir) {
53
+ const registryPath = path.join(globalBrainDir, 'config', 'project-registry.json');
54
+ const list = readJson(registryPath, []);
55
+ const out = { total: 0, created: 0, synced: 0, missing_brain: 0 };
56
+ if (!Array.isArray(list)) return out;
57
+ for (const row of list) {
58
+ out.total++;
59
+ if (!row?.brainDir || !fs.existsSync(row.brainDir)) {
60
+ out.missing_brain++;
61
+ continue;
62
+ }
63
+ const { project_id, created } = ensureProjectId(row.brainDir);
64
+ if (created) out.created++;
65
+ if (row.project_id !== project_id) {
66
+ row.project_id = project_id;
67
+ out.synced++;
68
+ }
69
+ }
70
+ if (out.synced) writeJsonAtomic(registryPath, list);
71
+ return out;
72
+ }
@@ -464,7 +464,7 @@ function matchesOnTokenBoundary(k, stem) {
464
464
  export function providerForKeyName(keyName) {
465
465
  // Strip packaging prefixes so DEVELOPER_BRAVE_SEARCH_API_KEY → BRAVE_SEARCH_API_KEY
466
466
  let k = String(keyName || '').toUpperCase();
467
- k = k.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_|PORTFOLIO_|ULTRACHAT_)+/g, '');
467
+ k = k.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_)+/g, '');
468
468
 
469
469
  // Tier 1 — exact pattern match.
470
470
  for (const p of PROVIDER_CATALOG) {
@@ -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,
@@ -67,7 +67,8 @@ export function mergeEnvManagedBlock(existing, managedBody) {
67
67
  export function projectSlugFromPath(projectPath) {
68
68
  if (!projectPath) return null;
69
69
  const abs = path.resolve(projectPath);
70
- return path.basename(abs);
70
+ // Bindings are stored trimmed, so a directory name with stray whitespace must match.
71
+ return path.basename(abs).trim();
71
72
  }
72
73
 
73
74
  /**
@@ -107,9 +108,7 @@ export function secretMatchesTarget(metaRow, target = {}) {
107
108
  // Explicit key list mode
108
109
  if (target.keys?.length) return true;
109
110
 
110
- // Bound to this repo by slug or path (policy: at most one product repo per key)
111
- // Multi-repo keys are invalid — do not project them until fixed
112
- if (repos.length > 1) return false;
111
+ // Bound to this repo by slug or path (a key may be bound to several repos)
113
112
  if (slug && repos.some((r) => r === slug || r === '*' || r.endsWith(`/${slug}`))) {
114
113
  return true;
115
114
  }
@@ -319,15 +319,14 @@ function appendAudit(brainDir, event) {
319
319
  }
320
320
 
321
321
  /**
322
- * Parse repos field. Policy: 0 (developer/tooling) or exactly 1 product repo.
323
- * Never allow multi-repo binding — each key is unique to one repo or to developer scope.
322
+ * Parse repos field. A credential may be bound to zero (developer/tooling),
323
+ * one, or several repos; uniqueness per repo is not required.
324
324
  *
325
325
  * @param {string|string[]|null|undefined} v
326
- * @param {{ strict?: boolean }} opts - strict throws if >1; false collapses for read-only display
326
+ * @param {object} [_opts] - accepted for compatibility; there is no limit
327
327
  * @returns {string[]}
328
328
  */
329
- export function normalizeReposBinding(v, opts = {}) {
330
- const strict = opts.strict !== false;
329
+ export function normalizeReposBinding(v, _opts = {}) {
331
330
  let list = [];
332
331
  if (Array.isArray(v)) {
333
332
  list = [...new Set(v.map(String).map((s) => s.trim()).filter(Boolean))];
@@ -341,18 +340,21 @@ export function normalizeReposBinding(v, opts = {}) {
341
340
  ),
342
341
  ];
343
342
  }
344
- if (strict && list.length > 1) {
345
- throw new Error(`A credential can be bound to at most ONE repo. Got ${list.length}: ${list.join(', ')}`);
346
- }
347
343
  return list;
348
344
  }
349
345
 
350
346
  /**
351
- * True when meta has multi-repo violation (legacy data).
347
+ * Multi-repo binding is allowed, so this is never a violation. Kept because
348
+ * callers still read the field.
352
349
  */
353
- export function isMultiRepoViolation(meta = {}) {
354
- const repos = Array.isArray(meta.repos) ? meta.repos.filter(Boolean) : [];
355
- return repos.length > 1;
350
+ export function isMultiRepoViolation(_meta = {}) {
351
+ return false;
352
+ }
353
+
354
+ /** Lowercase, de-duplicated tag list from an array or comma/space separated string. */
355
+ export function normalizeTags(v) {
356
+ const raw = Array.isArray(v) ? v : String(v ?? '').split(/[,;\s]+/);
357
+ return [...new Set(raw.map((t) => String(t).trim().toLowerCase()).filter(Boolean))];
356
358
  }
357
359
 
358
360
  /**
@@ -370,8 +372,9 @@ export function mergeSecretMeta(prev = {}, patch = {}) {
370
372
  assign('scope', (v) => (v === 'project' ? 'project' : 'global'));
371
373
  assign('provider', (v) => (v ? String(v).toLowerCase() : null));
372
374
  assign('label', (v) => (v == null ? null : String(v)));
373
- // Writes only — at most one product repo; empty = Developer secrets
374
- assign('repos', (v) => normalizeReposBinding(v, { strict: true }));
375
+ // empty = Developer secrets
376
+ assign('repos', (v) => normalizeReposBinding(v));
377
+ assign('tags', normalizeTags);
375
378
  assign('subscription_tier', (v) => (v == null || v === '' ? null : String(v)));
376
379
  assign('monthly_cost_usd', (v) => (v == null || v === '' ? null : Number(v)));
377
380
  assign('monthly_cap_usd', (v) => (v == null || v === '' ? null : Number(v)));
@@ -478,19 +481,13 @@ export function buildSharedValueIndex(rows = []) {
478
481
  const multiKey = keys.length > 1;
479
482
  // Error unless every member is operator-waived
480
483
  const allOk = members.every((m) => m.shared_value_ok === true);
481
- let severity = 'error';
482
- let error = null;
483
- if (allOk) {
484
- severity = 'ok';
485
- error = null;
486
- } else if (multiApp) {
487
- severity = 'error';
488
- error = `SHARED CREDENTIAL across apps/repos [${apps.join(', ')}] as keys [${keys.join(', ')}]. Issue a unique API key per app and re-bind.`;
489
- } else {
490
- // Same app/developer but duplicated under multiple secret names — still force unique
491
- severity = 'error';
492
- error = `SHARED CREDENTIAL value stored under ${keys.length} secret names [${keys.join(', ')}] (app=${apps[0]}). Use one key name or issue distinct values per purpose.`;
493
- }
484
+ // Sharing a value across keys or repos is allowed; it is reported for
485
+ // information only and never counts as unhealthy.
486
+ void allOk;
487
+ const severity = 'info';
488
+ const error = multiApp
489
+ ? `Shared value across [${apps.join(', ')}] as keys [${keys.join(', ')}] (allowed).`
490
+ : `Same value stored under ${keys.length} secret names [${keys.join(', ')}] (allowed).`;
494
491
 
495
492
  const group = {
496
493
  fingerprint: fp.slice(0, 12),
@@ -582,8 +579,9 @@ export async function listSecretsMeta(brainDir) {
582
579
  label: m.label || null,
583
580
  repos,
584
581
  repo: repos.length === 1 ? repos[0] : null,
585
- multi_repo_error: isMultiRepoViolation(m),
586
- binding_error: isMultiRepoViolation(m) ? `Legacy data error: Bound to ${repos.length} repos. Must be resolved to 1 or 0.` : null,
582
+ multi_repo_error: false,
583
+ binding_error: null,
584
+ tags: Array.isArray(m.tags) ? m.tags : [],
587
585
  project_path: m.project_path || null,
588
586
  subscription_tier: m.subscription_tier || null,
589
587
  monthly_cost_usd: m.monthly_cost_usd ?? null,
@@ -838,6 +836,7 @@ export async function setSecret(brainDir, key, value, opts = {}) {
838
836
  scope: opts.scope || prev.scope || 'global',
839
837
  provider: opts.provider || prev.provider || null,
840
838
  repos: opts.repos !== undefined ? opts.repos : prev.repos,
839
+ tags: opts.tags !== undefined ? opts.tags : prev.tags,
841
840
  subscription_tier: opts.subscription_tier !== undefined ? opts.subscription_tier : prev.subscription_tier,
842
841
  monthly_cost_usd: opts.monthly_cost_usd !== undefined ? opts.monthly_cost_usd : prev.monthly_cost_usd,
843
842
  monthly_cap_usd: opts.monthly_cap_usd !== undefined ? opts.monthly_cap_usd : prev.monthly_cap_usd,
@@ -860,6 +859,7 @@ export async function setSecret(brainDir, key, value, opts = {}) {
860
859
  scope: secrets[META_KEY].keys[key].scope,
861
860
  provider: secrets[META_KEY].keys[key].provider,
862
861
  actor: opts.actor || 'cli',
862
+ project_id: opts.project_id || null,
863
863
  });
864
864
 
865
865
  // First-time set only: optionally queue product-level API research (not raw key-name scraping).
@@ -907,6 +907,7 @@ export async function getSecret(brainDir, key, opts = {}) {
907
907
  action: opts.action || 'get',
908
908
  key,
909
909
  actor: opts.actor || 'cli',
910
+ project_id: opts.project_id || null,
910
911
  });
911
912
  return { found: true, key, value: secrets[key] };
912
913
  }