brainclaw 0.27.0 → 0.29.2

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.
@@ -1,5 +1,8 @@
1
1
  import path from 'node:path';
2
+ import { loadActiveProject } from './active-project.js';
3
+ import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from './brainclaw-version.js';
2
4
  import { loadConfig } from './config.js';
5
+ import { loadCurrentSession, loadAllSessions } from './identity.js';
3
6
  import { resolveCrossProjectLinks, loadCrossProjectState } from './cross-project.js';
4
7
  import { buildContextDiff } from './context-diff.js';
5
8
  import { resolveContextStoreCwd, resolveStoreChain } from './store-resolution.js';
@@ -502,6 +505,7 @@ export function buildContext(options = {}) {
502
505
  return undefined;
503
506
  }
504
507
  })(),
508
+ active_project: findActiveProjectInChain(contextCwd, storeChain),
505
509
  cross_project_items: crossProjectItems.length > 0 ? crossProjectItems : undefined,
506
510
  claim_conflicts: detectClaimConflicts(myClaims, otherActiveClaims),
507
511
  workflow_hints: buildWorkflowHints(myClaims, openWork, state.plan_items),
@@ -550,7 +554,43 @@ export function renderContextMarkdown(result, explain = false) {
550
554
  lines.push(`Agent ID: ${result.agent_id}`);
551
555
  }
552
556
  lines.push(`Project mode: ${result.project_mode} (${result.project_strategy})`);
557
+ if (result.active_project) {
558
+ const ap = result.active_project;
559
+ const age = Math.floor((Date.now() - Date.parse(ap.switched_at)) / 3_600_000);
560
+ const sourceHint = ap.source === 'session' ? ', session-scoped' : ', global';
561
+ lines.push(`Active project: ${ap.name ?? ap.path} (switched ${age}h ago by ${ap.switched_by ?? 'unknown'}${sourceHint})`);
562
+ if (ap.source === 'global') {
563
+ lines.push(` ⚠ This is a global switch — all agents on this host see the same project. Use \`brainclaw switch <project>\` during a session for agent-scoped switching.`);
564
+ }
565
+ lines.push(` All commands target this project. Use \`brainclaw switch --clear\` to return to workspace root or \`brainclaw switch <project>\` to change.`);
566
+ }
553
567
  lines.push(`Current host: ${result.current_host}`);
568
+ // Show other active sessions
569
+ try {
570
+ const allSessions = loadAllSessions();
571
+ const ttlMs = 4 * 60 * 60 * 1000;
572
+ const now = Date.now();
573
+ const otherSessions = allSessions.filter(s => s.agent_id !== result.agent_id
574
+ && (now - Date.parse(s.last_seen_at)) <= ttlMs);
575
+ if (otherSessions.length > 0) {
576
+ const summaries = otherSessions.map(s => {
577
+ const proj = s.active_project?.name ?? s.active_project?.path;
578
+ return `${s.user ?? 'unknown'}/${s.agent}${proj ? ` on ${proj}` : ''}`;
579
+ });
580
+ lines.push(`Other active agents: ${summaries.join(', ')}`);
581
+ }
582
+ }
583
+ catch { /* ignore — sessions dir may not exist yet */ }
584
+ // Check for brainclaw update (lightweight local manifest read only)
585
+ try {
586
+ const config = loadConfig();
587
+ const updateCheck = checkBrainclawInstallableUpdate(config, process.cwd());
588
+ const notice = renderBrainclawInstallableUpdateNotice(updateCheck);
589
+ if (notice) {
590
+ lines.push(`⚠ ${notice}`);
591
+ }
592
+ }
593
+ catch { /* ignore — update check is best-effort */ }
554
594
  lines.push(`Memory version: ${result.memory_version}`);
555
595
  lines.push(`Memory density: ${result.memory_density}`);
556
596
  lines.push(`Bootstrap available: ${result.bootstrap_available ? 'yes' : 'no'}`);
@@ -758,6 +798,11 @@ export function renderContextPromptTemplate(result, compact = false) {
758
798
  }
759
799
  lines.push(`project_mode: ${result.project_mode}`);
760
800
  lines.push(`project_strategy: ${result.project_strategy}`);
801
+ if (result.active_project) {
802
+ lines.push(`active_project: ${result.active_project.name ?? result.active_project.path}`);
803
+ lines.push(`active_project_switched: ${result.active_project.switched_at}`);
804
+ lines.push(`active_project_source: ${result.active_project.source ?? 'global'}`);
805
+ }
761
806
  lines.push(`current_host: ${result.current_host}`);
762
807
  lines.push(`memory_version: ${result.memory_version}`);
763
808
  lines.push(`memory_density: ${result.memory_density}`);
@@ -1303,6 +1348,34 @@ function scopesOverlap(a, b) {
1303
1348
  }
1304
1349
  return null;
1305
1350
  }
1351
+ // --- Active project resolution ---
1352
+ function findActiveProjectInChain(contextCwd, _storeChain) {
1353
+ // 1. Session-scoped active project (per-agent, highest priority)
1354
+ const session = loadCurrentSession(contextCwd);
1355
+ if (session?.active_project) {
1356
+ return {
1357
+ path: session.active_project.path,
1358
+ name: session.active_project.name,
1359
+ switched_at: session.active_project.switched_at,
1360
+ switched_by: session.agent,
1361
+ source: 'session',
1362
+ };
1363
+ }
1364
+ // 2. Global active-project.json (walk up from contextCwd)
1365
+ let dir = path.resolve(contextCwd);
1366
+ const root = path.parse(dir).root;
1367
+ const home = process.env.HOME || process.env.USERPROFILE || root;
1368
+ while (dir !== root && dir !== home) {
1369
+ const ap = loadActiveProject(dir);
1370
+ if (ap)
1371
+ return { ...ap, source: 'global' };
1372
+ const parent = path.dirname(dir);
1373
+ if (parent === dir)
1374
+ break;
1375
+ dir = parent;
1376
+ }
1377
+ return undefined;
1378
+ }
1306
1379
  // --- Workflow hints ---
1307
1380
  function buildWorkflowHints(myClaims, openWork, plans) {
1308
1381
  const hints = [];
@@ -12,6 +12,7 @@ export function appendEvent(event, cwd) {
12
12
  ts: event.ts ?? nowISO(),
13
13
  agent: event.agent ?? 'unknown',
14
14
  agent_id: event.agent_id,
15
+ user: event.user ?? process.env.USER ?? process.env.USERNAME,
15
16
  action: event.action,
16
17
  item_type: event.item_type,
17
18
  item_id: event.item_id,
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { requireRegisteredAgentIdentity } from './agent-registry.js';
5
6
  import { loadConfig } from './config.js';
@@ -7,7 +8,9 @@ import { resolveCurrentHostId } from './host.js';
7
8
  import { memoryDir } from './io.js';
8
9
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
9
10
  import { CurrentSessionStateSchema } from './schema.js';
10
- const CURRENT_SESSION_FILE = '.current-session';
11
+ const SESSIONS_DIR = 'sessions';
12
+ const LEGACY_SESSION_FILE = '.current-session';
13
+ // --- Public API ---
11
14
  export function resolveCurrentSessionId(env = process.env, cwd, options = {}) {
12
15
  const value = env.BRAINCLAW_SESSION_ID?.trim()
13
16
  || env.OPENCLAW_SESSION_ID?.trim()
@@ -64,11 +67,54 @@ export function resolveEventSessionId(event) {
64
67
  ? metadataSession
65
68
  : undefined;
66
69
  }
70
+ /**
71
+ * Load the current session for this agent+user combo.
72
+ * Checks sessions/ directory first, falls back to legacy .current-session.
73
+ */
67
74
  export function loadCurrentSession(cwd) {
68
- const filepath = currentSessionPath(cwd);
69
- if (!fs.existsSync(filepath)) {
70
- return undefined;
75
+ const dir = sessionsDir(cwd);
76
+ const currentUser = resolveCurrentUser();
77
+ const currentAgent = resolveCurrentAgentName();
78
+ // 1. Look in sessions/ directory for a matching session
79
+ if (fs.existsSync(dir)) {
80
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
81
+ const ttlMs = parseDurationToMs(loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
82
+ const now = Date.now();
83
+ for (const file of files) {
84
+ try {
85
+ const session = CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', path.join(dir, file)).document);
86
+ // Match by agent+user (or agent only if user not set in old sessions)
87
+ const userMatch = !session.user || !currentUser || session.user === currentUser;
88
+ const agentMatch = !currentAgent || session.agent === currentAgent;
89
+ const alive = (now - Date.parse(session.last_seen_at)) <= ttlMs;
90
+ if (userMatch && agentMatch && alive) {
91
+ return session;
92
+ }
93
+ }
94
+ catch {
95
+ // skip invalid session files
96
+ }
97
+ }
71
98
  }
99
+ // 2. Legacy fallback: .current-session
100
+ const legacyPath = path.join(memoryDir(cwd), LEGACY_SESSION_FILE);
101
+ if (fs.existsSync(legacyPath)) {
102
+ try {
103
+ return CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', legacyPath).document);
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+ /**
112
+ * Load a specific session by ID.
113
+ */
114
+ export function loadSessionById(sessionId, cwd) {
115
+ const filepath = sessionFilePath(sessionId, cwd);
116
+ if (!fs.existsSync(filepath))
117
+ return undefined;
72
118
  try {
73
119
  return CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', filepath).document);
74
120
  }
@@ -76,43 +122,134 @@ export function loadCurrentSession(cwd) {
76
122
  return undefined;
77
123
  }
78
124
  }
125
+ /**
126
+ * Load ALL sessions (active + stale) from the sessions/ directory.
127
+ */
128
+ export function loadAllSessions(cwd) {
129
+ const dir = sessionsDir(cwd);
130
+ if (!fs.existsSync(dir))
131
+ return [];
132
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
133
+ const sessions = [];
134
+ for (const file of files) {
135
+ try {
136
+ sessions.push(CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', path.join(dir, file)).document));
137
+ }
138
+ catch {
139
+ // skip invalid
140
+ }
141
+ }
142
+ return sessions.sort((a, b) => b.last_seen_at.localeCompare(a.last_seen_at));
143
+ }
144
+ /**
145
+ * Save a session to the sessions/ directory.
146
+ */
79
147
  export function saveCurrentSession(session, cwd) {
80
- saveVersionedJsonFile('current_session', currentSessionPath(cwd), CurrentSessionStateSchema.parse(session));
148
+ const dir = sessionsDir(cwd);
149
+ if (!fs.existsSync(dir)) {
150
+ fs.mkdirSync(dir, { recursive: true });
151
+ }
152
+ const filepath = sessionFilePath(session.session_id, cwd);
153
+ saveVersionedJsonFile('current_session', filepath, CurrentSessionStateSchema.parse(session));
81
154
  }
155
+ /**
156
+ * Clear a session. If sessionId is provided, only clear that specific session.
157
+ */
82
158
  export function clearCurrentSession(cwd, sessionId) {
83
- const filepath = currentSessionPath(cwd);
84
- if (!fs.existsSync(filepath)) {
159
+ if (sessionId) {
160
+ // Remove specific session file
161
+ const filepath = sessionFilePath(sessionId, cwd);
162
+ try {
163
+ fs.unlinkSync(filepath);
164
+ }
165
+ catch { /* ignore */ }
85
166
  return;
86
167
  }
87
- if (sessionId) {
88
- const current = loadCurrentSession(cwd);
89
- if (!current || current.session_id !== sessionId) {
90
- return;
168
+ // Clear the session for the current agent+user
169
+ const session = loadCurrentSession(cwd);
170
+ if (session) {
171
+ const filepath = sessionFilePath(session.session_id, cwd);
172
+ try {
173
+ fs.unlinkSync(filepath);
91
174
  }
175
+ catch { /* ignore */ }
92
176
  }
177
+ // Also clean legacy file
178
+ const legacyPath = path.join(memoryDir(cwd), LEGACY_SESSION_FILE);
93
179
  try {
94
- fs.unlinkSync(filepath);
180
+ fs.unlinkSync(legacyPath);
95
181
  }
96
- catch {
97
- // Ignore cleanup races.
182
+ catch { /* ignore */ }
183
+ }
184
+ /**
185
+ * Remove stale sessions that have exceeded the TTL.
186
+ * Returns the number of sessions removed.
187
+ */
188
+ export function gcStaleSessions(cwd, ttlOverride) {
189
+ const dir = sessionsDir(cwd);
190
+ if (!fs.existsSync(dir))
191
+ return 0;
192
+ const ttlMs = parseDurationToMs(ttlOverride ?? loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
193
+ const now = Date.now();
194
+ let removed = 0;
195
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
196
+ for (const file of files) {
197
+ try {
198
+ const session = CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', path.join(dir, file)).document);
199
+ if (now - Date.parse(session.last_seen_at) > ttlMs) {
200
+ fs.unlinkSync(path.join(dir, file));
201
+ removed++;
202
+ }
203
+ }
204
+ catch {
205
+ // Remove unparseable files too
206
+ try {
207
+ fs.unlinkSync(path.join(dir, file));
208
+ removed++;
209
+ }
210
+ catch { /* ignore */ }
211
+ }
98
212
  }
213
+ return removed;
214
+ }
215
+ // --- Internal helpers ---
216
+ function sessionsDir(cwd) {
217
+ return path.join(memoryDir(cwd), SESSIONS_DIR);
218
+ }
219
+ function sessionFilePath(sessionId, cwd) {
220
+ return path.join(sessionsDir(cwd), `${sessionId}.json`);
99
221
  }
100
- function currentSessionPath(cwd) {
101
- return path.join(memoryDir(cwd), CURRENT_SESSION_FILE);
222
+ function resolveCurrentUser() {
223
+ return process.env.USER || process.env.USERNAME || os.userInfo().username || undefined;
224
+ }
225
+ function resolveCurrentAgentName() {
226
+ return process.env.BRAINCLAW_AGENT_NAME || process.env.CLAUDE_CODE_VERSION ? 'claude-code' : undefined;
227
+ }
228
+ function loadConfigSafe(cwd) {
229
+ try {
230
+ return loadConfig(cwd);
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
102
235
  }
103
236
  function resolveImplicitSession(cwd, options) {
104
237
  const current = loadCurrentSession(cwd);
105
238
  const persistImplicit = options.persistImplicit ?? true;
106
- const ttlMs = parseDurationToMs(loadConfig(cwd).implicit_session_ttl ?? '4h');
239
+ const ttlMs = parseDurationToMs(loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
107
240
  const now = new Date();
241
+ const currentUser = resolveCurrentUser();
108
242
  if (current
109
243
  && current.agent === options.agentName
110
244
  && current.agent_id === options.agentId
111
245
  && current.host_id === options.hostId
246
+ && (!current.user || !currentUser || current.user === currentUser)
112
247
  && now.getTime() - Date.parse(current.last_seen_at) <= ttlMs) {
113
248
  const refreshed = {
114
249
  ...current,
115
250
  last_seen_at: now.toISOString(),
251
+ user: current.user || currentUser,
252
+ pid: process.pid,
116
253
  };
117
254
  if (persistImplicit) {
118
255
  saveCurrentSession(refreshed, cwd);
@@ -126,6 +263,8 @@ function resolveImplicitSession(cwd, options) {
126
263
  agent: options.agentName,
127
264
  agent_id: options.agentId,
128
265
  host_id: options.hostId,
266
+ user: currentUser,
267
+ pid: process.pid,
129
268
  };
130
269
  if (persistImplicit) {
131
270
  saveCurrentSession(created, cwd);
@@ -50,6 +50,16 @@ export function analyzeRepository(cwd) {
50
50
  if (matchedDirs.length > 0) {
51
51
  reasons.push(`Found top-level project folders: ${matchedDirs.join(', ')}`);
52
52
  }
53
+ // ── Signal 4: Multiple AGENTS.md files in the tree ──
54
+ const agentsFiles = findNestedAgentsFiles(cwd, 8);
55
+ if (agentsFiles.length > 1) {
56
+ reasons.push(`Found ${agentsFiles.length} AGENTS.md files: ${agentsFiles.slice(0, 5).join(', ')}${agentsFiles.length > 5 ? ` (+${agentsFiles.length - 5} more)` : ''}`);
57
+ }
58
+ // ── Signal 5: Multiple Dockerfiles or docker-compose files (service-oriented workspace) ──
59
+ const dockerComposeFiles = findFilesShallow(cwd, ['docker-compose.yml', 'docker-compose.yaml'], 6);
60
+ if (dockerComposeFiles.length > 1) {
61
+ reasons.push(`Found ${dockerComposeFiles.length} docker-compose files`);
62
+ }
53
63
  const packageJsonPath = path.join(cwd, 'package.json');
54
64
  if (fs.existsSync(packageJsonPath)) {
55
65
  try {
@@ -184,4 +194,61 @@ export function scanWorkspaceBoundaries(rootDir, maxDepth = 3) {
184
194
  walk(rootDir, 1);
185
195
  return { suggestions, alreadyInitialised };
186
196
  }
197
+ /**
198
+ * Find AGENTS.md files up to maxDepth levels deep.
199
+ * Returns relative paths from cwd.
200
+ */
201
+ export function findNestedAgentsFiles(cwd, maxDepth) {
202
+ const results = [];
203
+ function walk(dir, depth) {
204
+ if (depth > maxDepth || results.length > 10)
205
+ return;
206
+ let entries;
207
+ try {
208
+ entries = fs.readdirSync(dir, { withFileTypes: true });
209
+ }
210
+ catch {
211
+ return;
212
+ }
213
+ for (const entry of entries) {
214
+ if (entry.isFile() && entry.name === 'AGENTS.md') {
215
+ results.push(path.relative(cwd, path.join(dir, entry.name)));
216
+ }
217
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
218
+ walk(path.join(dir, entry.name), depth + 1);
219
+ }
220
+ }
221
+ }
222
+ walk(cwd, 0);
223
+ return results;
224
+ }
225
+ /**
226
+ * Find specific filenames up to maxDepth levels deep.
227
+ * Returns relative paths from cwd.
228
+ */
229
+ function findFilesShallow(cwd, filenames, maxDepth) {
230
+ const nameSet = new Set(filenames);
231
+ const results = [];
232
+ function walk(dir, depth) {
233
+ if (depth > maxDepth || results.length > 10)
234
+ return;
235
+ let entries;
236
+ try {
237
+ entries = fs.readdirSync(dir, { withFileTypes: true });
238
+ }
239
+ catch {
240
+ return;
241
+ }
242
+ for (const entry of entries) {
243
+ if (entry.isFile() && nameSet.has(entry.name)) {
244
+ results.push(path.relative(cwd, path.join(dir, entry.name)));
245
+ }
246
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
247
+ walk(path.join(dir, entry.name), depth + 1);
248
+ }
249
+ }
250
+ }
251
+ walk(cwd, 0);
252
+ return results;
253
+ }
187
254
  //# sourceMappingURL=repo-analysis.js.map
@@ -33,6 +33,30 @@ function coerceEffortToMinutes(val) {
33
33
  }
34
34
  return undefined;
35
35
  }
36
+ /** Coerce tags from JSON string to array when MCP clients serialize arrays as strings.
37
+ * Accepts: string[] (passthrough), '["a","b"]' (JSON parse), 'a,b' (comma split). */
38
+ function coerceTags(val) {
39
+ if (Array.isArray(val))
40
+ return val;
41
+ if (typeof val === 'string') {
42
+ const trimmed = val.trim();
43
+ if (trimmed.startsWith('[')) {
44
+ try {
45
+ const parsed = JSON.parse(trimmed);
46
+ if (Array.isArray(parsed))
47
+ return parsed;
48
+ }
49
+ catch { /* fall through */ }
50
+ }
51
+ if (trimmed.length > 0)
52
+ return trimmed.split(',').map(t => t.trim()).filter(Boolean);
53
+ return [];
54
+ }
55
+ return val;
56
+ }
57
+ /** Resilient tags schema that accepts string[] or JSON-serialized string. */
58
+ export const TagsSchema = z.preprocess(coerceTags, z.array(z.string()));
59
+ export const TagsWithDefaultSchema = z.preprocess(coerceTags, z.array(z.string()).default([]));
36
60
  // --- Entry schemas ---
37
61
  export const ConstraintStatusSchema = z.enum(['active', 'resolved', 'expired']);
38
62
  export const ConstraintCategorySchema = z.enum(['architecture', 'performance', 'security', 'reliability', 'compatibility', 'process', 'other']);
@@ -58,7 +82,7 @@ export const ConstraintSchema = z.object({
58
82
  status: ConstraintStatusSchema,
59
83
  category: ConstraintCategorySchema.optional(),
60
84
  scope: MemoryScopeSchema.optional(),
61
- tags: z.array(z.string()),
85
+ tags: TagsSchema,
62
86
  related_paths: z.array(z.string()).optional(),
63
87
  expires_at: z.string().optional(),
64
88
  });
@@ -78,7 +102,7 @@ export const DecisionSchema = z.object({
78
102
  scope: MemoryScopeSchema.optional(),
79
103
  related_paths: z.array(z.string()).optional(),
80
104
  plan_id: z.string().optional(),
81
- tags: z.array(z.string()),
105
+ tags: TagsSchema,
82
106
  });
83
107
  export const TrapSchema = z.object({
84
108
  schema_version: z.number().int().positive().optional(),
@@ -94,7 +118,7 @@ export const TrapSchema = z.object({
94
118
  status: TrapStatusSchema.default('active'),
95
119
  severity: SeveritySchema,
96
120
  scope: MemoryScopeSchema.optional(),
97
- tags: z.array(z.string()),
121
+ tags: TagsSchema,
98
122
  related_paths: z.array(z.string()).optional(),
99
123
  plan_id: z.string().optional(),
100
124
  visibility: MemoryVisibilitySchema.default('shared'),
@@ -119,7 +143,7 @@ export const HandoffSchema = z.object({
119
143
  status: HandoffStatusSchema,
120
144
  project: z.string().optional(),
121
145
  plan_id: z.string().optional(),
122
- tags: z.array(z.string()),
146
+ tags: TagsSchema,
123
147
  related_paths: z.array(z.string()).optional(),
124
148
  snapshot: z.object({
125
149
  diff: z.string().optional(),
@@ -150,7 +174,7 @@ export const PlanItemSchema = z.object({
150
174
  priority: PrioritySchema,
151
175
  assignee: z.string().optional(),
152
176
  project: z.string().optional(),
153
- tags: z.array(z.string()),
177
+ tags: TagsSchema,
154
178
  related_paths: z.array(z.string()).optional(),
155
179
  depends_on: z.array(z.string()).default([]),
156
180
  steps: z.array(PlanStepSchema).optional(),
@@ -170,7 +194,7 @@ export const InstructionEntrySchema = z.object({
170
194
  updated_at: z.string(),
171
195
  author: z.string(),
172
196
  model: z.string().optional(),
173
- tags: z.array(z.string()).default([]),
197
+ tags: TagsWithDefaultSchema,
174
198
  active: z.boolean().default(true),
175
199
  supersedes: z.string().optional(),
176
200
  });
@@ -183,7 +207,7 @@ export const ProjectCapabilitySchema = z.object({
183
207
  category: z.string(), // e.g. "auth", "api", "storage", "testing"
184
208
  provided_by: z.string().optional(), // path to implementation
185
209
  requires: z.array(z.string()).optional(), // capability IDs this depends on
186
- tags: z.array(z.string()),
210
+ tags: TagsSchema,
187
211
  example_usage: z.string().optional(),
188
212
  status: CapabilityStatusSchema.default('stable'),
189
213
  related_paths: z.array(z.string()).optional(),
@@ -205,7 +229,7 @@ export const ProjectToolSchema = z.object({
205
229
  requires: z.array(z.string()).optional(), // tool IDs this depends on
206
230
  suggests_for: z.array(z.string()).optional(), // agent types or domains
207
231
  invocation_example: z.string().optional(),
208
- tags: z.array(z.string()),
232
+ tags: TagsSchema,
209
233
  status: CapabilityStatusSchema.default('stable'),
210
234
  related_paths: z.array(z.string()).optional(),
211
235
  created_at: z.string(),
@@ -271,7 +295,7 @@ export const CandidateSchema = z.object({
271
295
  host_id: z.string().optional(),
272
296
  session_id: z.string().optional(),
273
297
  source: z.string().optional(),
274
- tags: z.array(z.string()),
298
+ tags: TagsSchema,
275
299
  status: CandidateStatusSchema,
276
300
  // type-specific optional fields
277
301
  severity: SeveritySchema.optional(),
@@ -324,6 +348,8 @@ export const ClaimSchema = z.object({
324
348
  id: z.string(),
325
349
  agent: z.string(),
326
350
  agent_id: z.string().optional(),
351
+ /** OS user who created this claim. */
352
+ user: z.string().optional(),
327
353
  project_id: z.string().optional(),
328
354
  host_id: z.string().optional(),
329
355
  session_id: z.string().optional(),
@@ -349,7 +375,7 @@ export const RuntimeNoteSchema = z.object({
349
375
  created_at: z.string(),
350
376
  project: z.string().optional(),
351
377
  plan_id: z.string().optional(),
352
- tags: z.array(z.string()),
378
+ tags: TagsSchema,
353
379
  visibility: MemoryVisibilitySchema.default('shared'),
354
380
  host_id: z.string().optional(),
355
381
  expires_at: z.string().optional(),
@@ -376,7 +402,7 @@ export const AiSurfaceTaskRequestSchema = z.object({
376
402
  status: AiSurfaceTaskStatusSchema.default('queued'),
377
403
  requested_outputs: z.array(z.string()).default([]),
378
404
  related_paths: z.array(z.string()).optional(),
379
- tags: z.array(z.string()).default([]),
405
+ tags: TagsWithDefaultSchema,
380
406
  claimed_at: z.string().optional(),
381
407
  completed_at: z.string().optional(),
382
408
  result_note: z.string().optional(),
@@ -402,7 +428,7 @@ export const RuntimeEventSchema = z.object({
402
428
  event_type: RuntimeEventTypeSchema,
403
429
  created_at: z.string(),
404
430
  text: z.string(),
405
- tags: z.array(z.string()).default([]),
431
+ tags: TagsWithDefaultSchema,
406
432
  // Optional routing and type hints for candidate generation
407
433
  candidate_type: CandidateTypeSchema.optional(),
408
434
  severity: SeveritySchema.optional(),
@@ -418,7 +444,7 @@ export const ProjectModeSchema = z.enum(['single-project', 'multi-project', 'aut
418
444
  export const ProjectStrategySchema = z.enum(['manual', 'folder']);
419
445
  export const TopologyModeSchema = z.enum(['embedded', 'sidecar', 'local-only']);
420
446
  export const IgnoreStrategySchema = z.enum(['project-gitignore', 'none']);
421
- export const AgentKindSchema = z.enum(['agent', 'human', 'unknown']);
447
+ export const AgentKindSchema = z.enum(['agent', 'autonomous', 'human', 'unknown']);
422
448
  export const AgentTrustLevelSchema = z.enum(['observer', 'contributor', 'trusted', 'curator']);
423
449
  export const AgentIdentityKeySchema = z.object({
424
450
  algorithm: z.literal('ed25519'),
@@ -468,6 +494,14 @@ export const SessionSnapshotSchema = z.object({
468
494
  initial_context_hash: z.string().optional(),
469
495
  git_sha: z.string().optional(),
470
496
  });
497
+ export const SessionActiveProjectSchema = z.object({
498
+ /** Absolute path to the project directory. */
499
+ path: z.string(),
500
+ /** Project name from config.yaml (when available). */
501
+ name: z.string().optional(),
502
+ /** ISO timestamp of the switch. */
503
+ switched_at: z.string(),
504
+ }).strict();
471
505
  export const CurrentSessionStateSchema = z.object({
472
506
  schema_version: z.number().int().positive().optional(),
473
507
  session_id: z.string(),
@@ -476,6 +510,12 @@ export const CurrentSessionStateSchema = z.object({
476
510
  agent: z.string(),
477
511
  agent_id: z.string(),
478
512
  host_id: z.string(),
513
+ /** OS user who started this session. */
514
+ user: z.string().optional(),
515
+ /** Process ID of the agent process (for liveness detection). */
516
+ pid: z.number().int().positive().optional(),
517
+ /** Session-scoped active project (overrides global active-project.json). */
518
+ active_project: SessionActiveProjectSchema.optional(),
479
519
  });
480
520
  export const MemorySeedKindSchema = z.enum([
481
521
  'command',
@@ -516,7 +556,7 @@ export const MemorySeedDocumentSchema = z.object({
516
556
  source_ref: z.string(),
517
557
  confidence: MemorySeedConfidenceSchema,
518
558
  related_paths: z.array(z.string()).optional(),
519
- tags: z.array(z.string()).default([]),
559
+ tags: TagsWithDefaultSchema,
520
560
  promotion_hint: z.enum(['constraint', 'decision', 'trap']).optional(),
521
561
  });
522
562
  export const BootstrapProfileDocumentSchema = z.object({
@@ -547,7 +587,7 @@ export const BootstrapSuggestionDocumentSchema = z.object({
547
587
  source_refs: z.array(z.string()).default([]),
548
588
  layer: z.enum(['global', 'project', 'agent']).optional(),
549
589
  scope: z.string().optional(),
550
- tags: z.array(z.string()).default([]),
590
+ tags: TagsWithDefaultSchema,
551
591
  related_paths: z.array(z.string()).optional(),
552
592
  category: ConstraintCategorySchema.optional(),
553
593
  outcome: DecisionOutcomeSchema.optional(),
@@ -581,7 +621,7 @@ export const BootstrapInterviewAnswerSuggestionSchema = z.object({
581
621
  confidence: MemorySeedConfidenceSchema.optional(),
582
622
  layer: z.enum(['global', 'project', 'agent']).optional(),
583
623
  scope: z.string().optional(),
584
- tags: z.array(z.string()).default([]),
624
+ tags: TagsWithDefaultSchema,
585
625
  related_paths: z.array(z.string()).optional(),
586
626
  category: ConstraintCategorySchema.optional(),
587
627
  outcome: DecisionOutcomeSchema.optional(),
@@ -638,6 +678,10 @@ export const AgentIntegrationNameSchema = z.enum([
638
678
  'continue',
639
679
  'roo',
640
680
  'openclaw',
681
+ 'nanoclaw',
682
+ 'nemoclaw',
683
+ 'picoclaw',
684
+ 'zeroclaw',
641
685
  ]);
642
686
  export const AgentIntegrationSurfaceKindSchema = z.enum(['instructions', 'mcp', 'skill', 'rule', 'hook']);
643
687
  export const AgentIntegrationLocationSchema = z.enum(['workspace', 'machine']);