@cleocode/core 2026.4.51 → 2026.4.53

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.
@@ -259,9 +259,18 @@ export async function searchBrainCompact(
259
259
  const returnedIds = results.map((r) => r.id);
260
260
  setImmediate(() => {
261
261
  incrementCitationCounts(projectRoot, returnedIds).catch(() => {});
262
- logRetrieval(projectRoot, query, returnedIds, 'find-rrf', results.length * 50).catch(
263
- () => {},
264
- );
262
+ getCurrentSessionId(projectRoot)
263
+ .then((sessionId) => {
264
+ return logRetrieval(
265
+ projectRoot,
266
+ query,
267
+ returnedIds,
268
+ 'find-rrf',
269
+ results.length * 50,
270
+ sessionId,
271
+ );
272
+ })
273
+ .catch(() => {});
265
274
  });
266
275
  }
267
276
 
@@ -345,7 +354,18 @@ export async function searchBrainCompact(
345
354
  const returnedIds = results.map((r) => r.id);
346
355
  setImmediate(() => {
347
356
  incrementCitationCounts(projectRoot, returnedIds).catch(() => {});
348
- logRetrieval(projectRoot, query, returnedIds, 'find', results.length * 50).catch(() => {});
357
+ getCurrentSessionId(projectRoot)
358
+ .then((sessionId) => {
359
+ return logRetrieval(
360
+ projectRoot,
361
+ query,
362
+ returnedIds,
363
+ 'find',
364
+ results.length * 50,
365
+ sessionId,
366
+ );
367
+ })
368
+ .catch(() => {});
349
369
  });
350
370
  }
351
371
 
@@ -604,13 +624,18 @@ export async function fetchBrainEntries(
604
624
  const fetchedIds = results.map((r) => r.id);
605
625
  setImmediate(() => {
606
626
  incrementCitationCounts(projectRoot, fetchedIds).catch(() => {});
607
- logRetrieval(
608
- projectRoot,
609
- fetchedIds.join(','),
610
- fetchedIds,
611
- 'fetch',
612
- results.length * 500,
613
- ).catch(() => {});
627
+ getCurrentSessionId(projectRoot)
628
+ .then((sessionId) => {
629
+ return logRetrieval(
630
+ projectRoot,
631
+ fetchedIds.join(','),
632
+ fetchedIds,
633
+ 'fetch',
634
+ results.length * 500,
635
+ sessionId,
636
+ );
637
+ })
638
+ .catch(() => {});
614
639
  });
615
640
  }
616
641
 
@@ -1364,6 +1389,31 @@ export async function retrieveWithBudget(
1364
1389
  };
1365
1390
  }
1366
1391
 
1392
+ // ============================================================================
1393
+ // Session ID Retrieval (for logRetrieval)
1394
+ // ============================================================================
1395
+
1396
+ /**
1397
+ * Get the current session ID from the session manager.
1398
+ *
1399
+ * This is a best-effort operation — if no session is active or session
1400
+ * manager is unavailable, returns null. Used by logRetrieval to group
1401
+ * retrievals by session for STDP analysis.
1402
+ *
1403
+ * @param projectRoot - Project root directory
1404
+ * @returns Current session ID or null if unavailable
1405
+ */
1406
+ async function getCurrentSessionId(projectRoot: string): Promise<string | undefined> {
1407
+ try {
1408
+ const { sessionStatus } = await import('../sessions/index.js');
1409
+ const session = await sessionStatus(projectRoot);
1410
+ return session?.id;
1411
+ } catch {
1412
+ // Session manager unavailable or other error — log retrievals without session
1413
+ return undefined;
1414
+ }
1415
+ }
1416
+
1367
1417
  // ============================================================================
1368
1418
  // Citation Count Increment (non-blocking helper)
1369
1419
  // ============================================================================
@@ -1416,6 +1466,13 @@ async function incrementCitationCounts(projectRoot: string, ids: string[]): Prom
1416
1466
  *
1417
1467
  * Creates the table on first use if it doesn't exist (self-healing).
1418
1468
  * Best-effort: errors are silently swallowed.
1469
+ *
1470
+ * @param projectRoot - Project root directory
1471
+ * @param query - The search query or fetch IDs
1472
+ * @param entryIds - Array of entry IDs returned in this retrieval
1473
+ * @param source - Retrieval source ('find', 'fetch', 'hybrid', 'timeline', 'budget')
1474
+ * @param tokensUsed - Estimated tokens consumed (optional)
1475
+ * @param sessionId - Session ID for grouping retrievals by session (optional, soft FK to tasks.db)
1419
1476
  */
1420
1477
  async function logRetrieval(
1421
1478
  projectRoot: string,
@@ -1423,6 +1480,7 @@ async function logRetrieval(
1423
1480
  entryIds: string[],
1424
1481
  source: string,
1425
1482
  tokensUsed?: number,
1483
+ sessionId?: string,
1426
1484
  ): Promise<void> {
1427
1485
  if (entryIds.length === 0) return;
1428
1486
 
@@ -1431,7 +1489,7 @@ async function logRetrieval(
1431
1489
  const nativeDb = getBrainNativeDb();
1432
1490
  if (!nativeDb) return;
1433
1491
 
1434
- // Self-healing: create table if not exists
1492
+ // Self-healing: create table if not exists (includes session_id column)
1435
1493
  const createSql =
1436
1494
  'CREATE TABLE IF NOT EXISTS brain_retrieval_log (' +
1437
1495
  'id INTEGER PRIMARY KEY AUTOINCREMENT,' +
@@ -1440,6 +1498,7 @@ async function logRetrieval(
1440
1498
  'entry_count INTEGER NOT NULL,' +
1441
1499
  'source TEXT NOT NULL,' +
1442
1500
  'tokens_used INTEGER,' +
1501
+ 'session_id TEXT,' +
1443
1502
  "created_at TEXT NOT NULL DEFAULT (datetime('now'))" +
1444
1503
  ')';
1445
1504
  try {
@@ -1451,9 +1510,16 @@ async function logRetrieval(
1451
1510
  try {
1452
1511
  nativeDb
1453
1512
  .prepare(
1454
- 'INSERT INTO brain_retrieval_log (query, entry_ids, entry_count, source, tokens_used) VALUES (?, ?, ?, ?, ?)',
1513
+ 'INSERT INTO brain_retrieval_log (query, entry_ids, entry_count, source, tokens_used, session_id) VALUES (?, ?, ?, ?, ?, ?)',
1455
1514
  )
1456
- .run(query, entryIds.join(','), entryIds.length, source, tokensUsed ?? null);
1515
+ .run(
1516
+ query,
1517
+ entryIds.join(','),
1518
+ entryIds.length,
1519
+ source,
1520
+ tokensUsed ?? null,
1521
+ sessionId ?? null,
1522
+ );
1457
1523
  } catch {
1458
1524
  /* best-effort */
1459
1525
  }
@@ -61,7 +61,7 @@ export async function searchSimilar(
61
61
  projectRoot: string,
62
62
  limit?: number,
63
63
  ): Promise<SimilarityResult[]> {
64
- if (!query || !query.trim()) return [];
64
+ if (!query?.trim()) return [];
65
65
  if (!isEmbeddingAvailable()) return [];
66
66
 
67
67
  const maxResults = limit ?? 10;
@@ -316,7 +316,7 @@ export async function migrateClaudeMem(
316
316
 
317
317
  try {
318
318
  for (const row of batch) {
319
- if (!row.learned || !row.learned.trim()) {
319
+ if (!row.learned?.trim()) {
320
320
  continue;
321
321
  }
322
322
 
package/src/scaffold.ts CHANGED
@@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto';
15
15
  import type { Dirent } from 'node:fs';
16
16
  import { existsSync, constants as fsConstants, readFileSync, statSync } from 'node:fs';
17
17
  import { access, copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
18
+ import { createRequire } from 'node:module';
18
19
  import { homedir as getHomedir } from 'node:os';
19
20
  import { dirname, join, resolve } from 'node:path';
20
21
  import { fileURLToPath } from 'node:url';
@@ -1836,8 +1837,159 @@ export async function ensureCleoOsHub(): Promise<ScaffoldResult> {
1836
1837
  };
1837
1838
  }
1838
1839
 
1840
+ /**
1841
+ * Resolve the source location of CLEOOS-IDENTITY.md from the cleo-os starter bundle.
1842
+ *
1843
+ * Search order:
1844
+ * 1. Monorepo development: `packages/cleo-os/starter-bundle/CLEOOS-IDENTITY.md`
1845
+ * 2. Installed package: `node_modules/@cleocode/cleo-os/starter-bundle/CLEOOS-IDENTITY.md`
1846
+ *
1847
+ * @returns Absolute path to the source identity file, or null if not found.
1848
+ * @internal Used by ensureGlobalIdentity.
1849
+ */
1850
+ function resolveIdentitySourcePath(): string | null {
1851
+ // Prefer monorepo source (development)
1852
+ const monorepoPath = join(
1853
+ process.cwd(),
1854
+ 'packages',
1855
+ 'cleo-os',
1856
+ 'starter-bundle',
1857
+ 'CLEOOS-IDENTITY.md',
1858
+ );
1859
+ if (existsSync(monorepoPath)) return monorepoPath;
1860
+
1861
+ // Fall back to installed package via require resolution
1862
+ try {
1863
+ const require = createRequire(import.meta.url);
1864
+ const pkgJson = require.resolve('@cleocode/cleo-os/package.json');
1865
+ const pkgDir = pkgJson.replace(/\/package\.json$/, '');
1866
+ const installedPath = join(pkgDir, 'starter-bundle', 'CLEOOS-IDENTITY.md');
1867
+ if (existsSync(installedPath)) return installedPath;
1868
+ } catch {
1869
+ // Not installed — fall through
1870
+ }
1871
+
1872
+ return null;
1873
+ }
1874
+
1875
+ /**
1876
+ * Ensure the Cleo Prime identity file is deployed to the global XDG path.
1877
+ *
1878
+ * SSoT architecture (T631): CLEOOS-IDENTITY.md lives ONCE at the global path
1879
+ * (`~/.local/share/cleo/CLEOOS-IDENTITY.md`). Per-project override is OPTIONAL —
1880
+ * a project may place a customized copy at `.cleo/CLEOOS-IDENTITY.md` and the
1881
+ * loader (`cant-context.ts readIdentityFile`) reads project-first then global.
1882
+ *
1883
+ * Called by both `cleo init` (init.ts deployStarterBundle) and `cleo upgrade`
1884
+ * (upgrade.ts) so existing projects self-heal on upgrade if the file is missing
1885
+ * or out of date.
1886
+ *
1887
+ * Idempotent. Always overwrites if `forceRefresh` is true (used by `upgrade
1888
+ * --refresh-identity`); otherwise only writes when missing.
1889
+ *
1890
+ * @param forceRefresh - Overwrite even if the file exists. Default false.
1891
+ * @returns ScaffoldResult describing what happened.
1892
+ */
1893
+ export async function ensureGlobalIdentity(forceRefresh = false): Promise<ScaffoldResult> {
1894
+ const sourcePath = resolveIdentitySourcePath();
1895
+ if (!sourcePath) {
1896
+ return {
1897
+ action: 'skipped',
1898
+ path: '',
1899
+ details: 'CLEOOS-IDENTITY.md source not found in monorepo or installed package',
1900
+ };
1901
+ }
1902
+
1903
+ const cleoHome = getCleoHome();
1904
+ const dst = join(cleoHome, 'CLEOOS-IDENTITY.md');
1905
+
1906
+ try {
1907
+ await mkdir(cleoHome, { recursive: true });
1908
+ } catch (err) {
1909
+ return {
1910
+ action: 'skipped',
1911
+ path: dst,
1912
+ details: `Failed to create global cleo home: ${err instanceof Error ? err.message : String(err)}`,
1913
+ };
1914
+ }
1915
+
1916
+ if (existsSync(dst) && !forceRefresh) {
1917
+ return { action: 'skipped', path: dst, details: 'identity already present' };
1918
+ }
1919
+
1920
+ const existedBefore = existsSync(dst);
1921
+ try {
1922
+ const content = readFileSync(sourcePath, 'utf-8');
1923
+ await writeFile(dst, content);
1924
+ return {
1925
+ action: existedBefore ? 'repaired' : 'created',
1926
+ path: dst,
1927
+ details: `from ${sourcePath}`,
1928
+ };
1929
+ } catch (err) {
1930
+ return {
1931
+ action: 'skipped',
1932
+ path: dst,
1933
+ details: `Failed to write identity: ${err instanceof Error ? err.message : String(err)}`,
1934
+ };
1935
+ }
1936
+ }
1937
+
1839
1938
  // ── Global check* functions (read-only diagnostics) ──────────────────
1840
1939
 
1940
+ /**
1941
+ * Check that the global CLEOOS-IDENTITY.md file is present and non-empty.
1942
+ * Read-only diagnostic for `cleo doctor`.
1943
+ *
1944
+ * @returns Check result with status, path details, and self-heal command.
1945
+ *
1946
+ * @remarks
1947
+ * Used by `cleo doctor` to verify the orchestrator persona is installed.
1948
+ * Self-heal: `cleo upgrade --refresh-identity` re-deploys from source.
1949
+ */
1950
+ export function checkGlobalIdentity(): CheckResult {
1951
+ const cleoHome = getCleoHome();
1952
+ const identityPath = join(cleoHome, 'CLEOOS-IDENTITY.md');
1953
+
1954
+ if (!existsSync(identityPath)) {
1955
+ return {
1956
+ id: 'global_identity',
1957
+ category: 'global',
1958
+ status: 'failed',
1959
+ message: 'Global CLEOOS-IDENTITY.md not found — orchestrator persona missing',
1960
+ details: { path: identityPath, exists: false },
1961
+ fix: 'cleo upgrade (auto-deploys identity)',
1962
+ };
1963
+ }
1964
+
1965
+ let size = 0;
1966
+ try {
1967
+ size = statSync(identityPath).size;
1968
+ } catch {
1969
+ /* ignore */
1970
+ }
1971
+
1972
+ if (size === 0) {
1973
+ return {
1974
+ id: 'global_identity',
1975
+ category: 'global',
1976
+ status: 'failed',
1977
+ message: 'Global CLEOOS-IDENTITY.md exists but is empty',
1978
+ details: { path: identityPath, exists: true, size: 0 },
1979
+ fix: 'cleo upgrade --refresh-identity',
1980
+ };
1981
+ }
1982
+
1983
+ return {
1984
+ id: 'global_identity',
1985
+ category: 'global',
1986
+ status: 'passed',
1987
+ message: 'Global CLEOOS-IDENTITY.md present',
1988
+ details: { path: identityPath, exists: true, size },
1989
+ fix: '',
1990
+ };
1991
+ }
1992
+
1841
1993
  /**
1842
1994
  * Check that the global ~/.cleo/ home and its required subdirectories exist.
1843
1995
  * Read-only: no side effects.
@@ -292,7 +292,7 @@ async function computeLastSession(
292
292
  const allSessions = await accessor.loadSessions();
293
293
 
294
294
  const session = allSessions.find((s) => s.id === sessionId);
295
- if (!session || !session.endedAt) return null;
295
+ if (!session?.endedAt) return null;
296
296
 
297
297
  // Calculate duration if startedAt is available
298
298
  let duration = 0;
@@ -382,7 +382,7 @@ export function prepareSpawnMulti(
382
382
  const isPrimary = i === 0;
383
383
 
384
384
  const skill = findSkill(skillName, cwd);
385
- if (!skill || !skill.content) {
385
+ if (!skill?.content) {
386
386
  continue;
387
387
  }
388
388
 
@@ -205,7 +205,7 @@ export async function orchestratorSpawnSkill(
205
205
  ): Promise<string> {
206
206
  // Find the skill
207
207
  const skill = findSkill(skillName, cwd);
208
- if (!skill || !skill.content) {
208
+ if (!skill?.content) {
209
209
  throw new CleoError(ExitCode.NOT_FOUND, `Skill not found: ${skillName}`, {
210
210
  fix: `Check skills directory for ${skillName}/SKILL.md`,
211
211
  });
@@ -45,7 +45,7 @@ export async function buildPrompt(
45
45
 
46
46
  // Find skill template
47
47
  const skill = findSkill(templateName, cwd);
48
- if (!skill || !skill.content) {
48
+ if (!skill?.content) {
49
49
  const { canonical } = mapSkillName(templateName);
50
50
  throw new CleoError(ExitCode.NOT_FOUND, `Skill template ${templateName} not found`, {
51
51
  fix: `Expected at skills/${canonical}/SKILL.md`,
@@ -707,11 +707,15 @@ export const brainRetrievalLog = sqliteTable(
707
707
  /** Estimated tokens consumed by this retrieval. */
708
708
  tokensUsed: integer('tokens_used'),
709
709
 
710
+ /** Session ID (soft FK to tasks.db sessions). Enables grouping retrievals by session for STDP analysis. */
711
+ sessionId: text('session_id'),
712
+
710
713
  createdAt: text('created_at').notNull().default(sql`(datetime('now'))`),
711
714
  },
712
715
  (table) => [
713
716
  index('idx_retrieval_log_created').on(table.createdAt),
714
717
  index('idx_retrieval_log_source').on(table.source),
718
+ index('idx_retrieval_log_session').on(table.sessionId),
715
719
  ],
716
720
  );
717
721
 
package/src/store/json.ts CHANGED
@@ -206,7 +206,7 @@ export async function readLogEntries(filePath: string): Promise<Record<string, u
206
206
  if (remainder) {
207
207
  for (const line of remainder.split('\n')) {
208
208
  const l = line.trim();
209
- if (!l || !l.startsWith('{')) continue;
209
+ if (!l?.startsWith('{')) continue;
210
210
  try {
211
211
  entries.push(JSON.parse(l) as Record<string, unknown>);
212
212
  } catch {
@@ -219,7 +219,7 @@ export async function readLogEntries(filePath: string): Promise<Record<string, u
219
219
  // Pure JSONL (no initial JSON object)
220
220
  for (const line of trimmed.split('\n')) {
221
221
  const l = line.trim();
222
- if (!l || !l.startsWith('{')) continue;
222
+ if (!l?.startsWith('{')) continue;
223
223
  try {
224
224
  entries.push(JSON.parse(l) as Record<string, unknown>);
225
225
  } catch {
@@ -420,7 +420,7 @@ export async function analyzeArchive(
420
420
 
421
421
  const reportType = opts.report ?? 'summary';
422
422
 
423
- if (!data || !data.archivedTasks?.length) {
423
+ if (!data?.archivedTasks?.length) {
424
424
  return {
425
425
  report: reportType,
426
426
  filters: null,
@@ -18,6 +18,7 @@ import {
18
18
  checkCleoStructure,
19
19
  checkConfig,
20
20
  checkGlobalHome,
21
+ checkGlobalIdentity,
21
22
  checkGlobalTemplates,
22
23
  checkLogDir,
23
24
  checkMemoryBridge,
@@ -842,6 +843,7 @@ export async function coreDoctorReport(projectRoot: string): Promise<DoctorRepor
842
843
  // 5c. Global scaffold checks: home, templates, schemas
843
844
  checks.push(mapCheckResult(checkGlobalHome()));
844
845
  checks.push(mapCheckResult(checkGlobalTemplates()));
846
+ checks.push(mapCheckResult(checkGlobalIdentity()));
845
847
  checks.push(mapSchemaCheckResult(checkGlobalSchemas()));
846
848
 
847
849
  // 5d. Project scaffold checks: log dir, structure, git hooks, project-info, injection
@@ -144,7 +144,7 @@ function measureDependencyDepth(
144
144
  visited.add(taskId);
145
145
 
146
146
  const task = taskMap.get(taskId);
147
- if (!task || !task.depends || task.depends.length === 0) return 0;
147
+ if (!task?.depends || task.depends.length === 0) return 0;
148
148
 
149
149
  let maxDepth = 0;
150
150
  for (const depId of task.depends) {
@@ -921,7 +921,7 @@ export async function coreTaskUnarchive(
921
921
  }
922
922
 
923
923
  const archive = await accessor.loadArchive();
924
- if (!archive || !archive.archivedTasks) {
924
+ if (!archive?.archivedTasks) {
925
925
  throw new Error('No archive file found');
926
926
  }
927
927
 
package/src/upgrade.ts CHANGED
@@ -940,6 +940,23 @@ export async function runUpgrade(
940
940
  /* best-effort */
941
941
  }
942
942
 
943
+ // Ensure global CLEOOS-IDENTITY.md is present (T631 — single SSoT).
944
+ // Self-heals if file is missing on existing projects after upgrade.
945
+ try {
946
+ const { ensureGlobalIdentity } = await import('./scaffold.js');
947
+ const identityResult = await ensureGlobalIdentity();
948
+ actions.push({
949
+ action: 'global_identity',
950
+ status:
951
+ identityResult.action === 'created' || identityResult.action === 'repaired'
952
+ ? 'applied'
953
+ : 'skipped',
954
+ details: `${identityResult.path} (${identityResult.details ?? identityResult.action})`,
955
+ });
956
+ } catch {
957
+ /* best-effort — identity is already-kept-or-skipped on failure */
958
+ }
959
+
943
960
  // Install core skills
944
961
  try {
945
962
  const skillsCreated: string[] = [];
@@ -438,9 +438,7 @@ export function allEpicChildrenVerified(epicId: string, tasks: TaskForVerificati
438
438
  const incomplete = children.filter((t) => t.status !== 'done');
439
439
  if (incomplete.length > 0) return false;
440
440
 
441
- const unverified = children.filter(
442
- (t) => t.status === 'done' && (!t.verification || !t.verification.passed),
443
- );
441
+ const unverified = children.filter((t) => t.status === 'done' && !t.verification?.passed);
444
442
  return unverified.length === 0;
445
443
  }
446
444
 
@@ -451,9 +449,7 @@ export function allEpicChildrenVerified(epicId: string, tasks: TaskForVerificati
451
449
  export function allSiblingsVerified(parentId: string, tasks: TaskForVerification[]): boolean {
452
450
  const siblings = tasks.filter((t) => t.parentId === parentId);
453
451
 
454
- const unverifiedDone = siblings.filter(
455
- (t) => t.status === 'done' && (!t.verification || !t.verification.passed),
456
- );
452
+ const unverifiedDone = siblings.filter((t) => t.status === 'done' && !t.verification?.passed);
457
453
 
458
454
  const incomplete = siblings.filter(
459
455
  (t) => t.status === 'pending' || t.status === 'active' || t.status === 'blocked',