@cleocode/core 2026.4.99 → 2026.4.100

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.
@@ -0,0 +1,1100 @@
1
+ // packages/core/src/sentient/daemon.ts
2
+ import { spawn as spawn2 } from "node:child_process";
3
+ import { createWriteStream, constants as fsConstants, watch } from "node:fs";
4
+ import { open as fsOpen, mkdir as mkdir2 } from "node:fs/promises";
5
+ import { join as join3 } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import cron from "node-cron";
8
+
9
+ // packages/core/src/sentient/ingesters/brain-ingester.ts
10
+ var BRAIN_INGESTER_LIMIT = 10;
11
+ var BRAIN_MIN_CITATION_COUNT = 3;
12
+ var BRAIN_MIN_QUALITY_SCORE = 0.5;
13
+ var BRAIN_LOOKBACK_DAYS = 7;
14
+ function computeBrainWeight(citationCount, qualityScore) {
15
+ return Math.min(citationCount / 10 * qualityScore, 1);
16
+ }
17
+ function runBrainIngester(nativeDb) {
18
+ if (!nativeDb) {
19
+ return [];
20
+ }
21
+ try {
22
+ const stmt = nativeDb.prepare(`
23
+ SELECT id, title, text, citation_count, quality_score
24
+ FROM brain_observations
25
+ WHERE type IN ('bugfix', 'decision')
26
+ AND citation_count >= :minCitations
27
+ AND created_at >= datetime('now', :lookback)
28
+ AND quality_score >= :minQuality
29
+ ORDER BY citation_count DESC, quality_score DESC
30
+ LIMIT :limit
31
+ `);
32
+ const rows = stmt.all({
33
+ minCitations: BRAIN_MIN_CITATION_COUNT,
34
+ lookback: `-${BRAIN_LOOKBACK_DAYS} days`,
35
+ minQuality: BRAIN_MIN_QUALITY_SCORE,
36
+ limit: BRAIN_INGESTER_LIMIT
37
+ });
38
+ const candidates = rows.map((row) => {
39
+ const label = row.title ?? row.text.slice(0, 80);
40
+ return {
41
+ source: "brain",
42
+ sourceId: row.id,
43
+ title: `[T2-BRAIN] Recurring issue: ${label}`,
44
+ rationale: `Brain entry ${row.id} cited ${row.citation_count} times (quality ${row.quality_score.toFixed(2)}) in the last ${BRAIN_LOOKBACK_DAYS} days`,
45
+ weight: computeBrainWeight(row.citation_count, row.quality_score)
46
+ };
47
+ });
48
+ candidates.sort((a, b) => b.weight - a.weight);
49
+ return candidates;
50
+ } catch (err) {
51
+ const message = err instanceof Error ? err.message : String(err);
52
+ process.stderr.write(`[sentient/brain-ingester] WARNING: ${message}
53
+ `);
54
+ return [];
55
+ }
56
+ }
57
+
58
+ // packages/core/src/sentient/ingesters/nexus-ingester.ts
59
+ var NEXUS_BASE_WEIGHT = 0.3;
60
+ var NEXUS_MIN_CALLER_COUNT = 5;
61
+ var NEXUS_MIN_DEGREE = 20;
62
+ var NEXUS_QUERY_LIMIT = 5;
63
+ function toFingerprint(nodeId) {
64
+ return nodeId;
65
+ }
66
+ function runNexusIngester(nativeDb) {
67
+ if (!nativeDb) {
68
+ return [];
69
+ }
70
+ const seenIds = /* @__PURE__ */ new Set();
71
+ const candidates = [];
72
+ try {
73
+ const stmtA = nativeDb.prepare(`
74
+ SELECT n.id, n.name, n.file_path, COUNT(r.id) as caller_count
75
+ FROM nexus_nodes n
76
+ JOIN nexus_relations r ON r.target_id = n.id AND r.kind = 'calls'
77
+ WHERE NOT EXISTS (
78
+ SELECT 1 FROM nexus_relations r2
79
+ WHERE r2.source_id = n.id AND r2.kind = 'calls'
80
+ )
81
+ AND n.kind = 'function'
82
+ GROUP BY n.id
83
+ HAVING caller_count > :minCallers
84
+ ORDER BY caller_count DESC
85
+ LIMIT :limit
86
+ `);
87
+ const rowsA = stmtA.all({
88
+ minCallers: NEXUS_MIN_CALLER_COUNT,
89
+ limit: NEXUS_QUERY_LIMIT
90
+ });
91
+ for (const row of rowsA) {
92
+ const fp = toFingerprint(row.id);
93
+ if (seenIds.has(fp)) continue;
94
+ seenIds.add(fp);
95
+ candidates.push({
96
+ source: "nexus",
97
+ sourceId: row.id,
98
+ title: `[T2-NEXUS] Over-coupled symbol: ${row.name} (${row.caller_count} callers)`,
99
+ rationale: `Function ${row.name} in ${row.file_path} has ${row.caller_count} callers but makes no outbound calls \u2014 review for abstraction opportunity`,
100
+ weight: NEXUS_BASE_WEIGHT
101
+ });
102
+ }
103
+ } catch (err) {
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ process.stderr.write(`[sentient/nexus-ingester] WARNING query A: ${message}
106
+ `);
107
+ }
108
+ try {
109
+ const stmtB = nativeDb.prepare(`
110
+ SELECT n.id, n.name, n.file_path, COUNT(r.id) as degree
111
+ FROM nexus_nodes n
112
+ JOIN nexus_relations r ON r.source_id = n.id OR r.target_id = n.id
113
+ GROUP BY n.id
114
+ HAVING degree > :minDegree
115
+ ORDER BY degree DESC
116
+ LIMIT :limit
117
+ `);
118
+ const rowsB = stmtB.all({
119
+ minDegree: NEXUS_MIN_DEGREE,
120
+ limit: NEXUS_QUERY_LIMIT
121
+ });
122
+ for (const row of rowsB) {
123
+ const fp = toFingerprint(row.id);
124
+ if (seenIds.has(fp)) continue;
125
+ seenIds.add(fp);
126
+ candidates.push({
127
+ source: "nexus",
128
+ sourceId: row.id,
129
+ title: `[T2-NEXUS] Over-coupled symbol: ${row.name} (${row.degree} edges)`,
130
+ rationale: `Symbol ${row.name} in ${row.file_path} has ${row.degree} total edges \u2014 review for over-coupling`,
131
+ weight: NEXUS_BASE_WEIGHT
132
+ });
133
+ }
134
+ } catch (err) {
135
+ const message = err instanceof Error ? err.message : String(err);
136
+ process.stderr.write(`[sentient/nexus-ingester] WARNING query B: ${message}
137
+ `);
138
+ }
139
+ return candidates;
140
+ }
141
+
142
+ // packages/core/src/sentient/ingesters/test-ingester.ts
143
+ import { readFileSync } from "node:fs";
144
+ import { join } from "node:path";
145
+ var GATES_JSONL_PATH = ".cleo/audit/gates.jsonl";
146
+ var COVERAGE_SUMMARY_PATH = ".cleo/coverage-summary.json";
147
+ var MIN_LINE_COVERAGE_PCT = 80;
148
+ var TEST_BASE_WEIGHT = 0.5;
149
+ function runGatesIngester(projectRoot) {
150
+ const gatesPath = join(projectRoot, GATES_JSONL_PATH);
151
+ let raw;
152
+ try {
153
+ raw = readFileSync(gatesPath, "utf-8");
154
+ } catch {
155
+ return [];
156
+ }
157
+ const candidates = [];
158
+ const seenKeys = /* @__PURE__ */ new Set();
159
+ for (const line of raw.split("\n")) {
160
+ const trimmed = line.trim();
161
+ if (!trimmed) continue;
162
+ let record;
163
+ try {
164
+ record = JSON.parse(trimmed);
165
+ } catch {
166
+ continue;
167
+ }
168
+ const taskId = record.taskId;
169
+ const gate = record.gate ?? "unknown";
170
+ const failCount = record.failCount ?? 0;
171
+ if (typeof taskId !== "string" || failCount <= 0) continue;
172
+ const key = `${taskId}.${gate}`;
173
+ if (seenKeys.has(key)) continue;
174
+ seenKeys.add(key);
175
+ candidates.push({
176
+ source: "test",
177
+ sourceId: key,
178
+ title: `[T2-TEST] Fix flaky gate: ${taskId}.${gate}`,
179
+ rationale: `Gate '${gate}' on task ${taskId} has failed ${failCount} time(s)`,
180
+ weight: TEST_BASE_WEIGHT
181
+ });
182
+ }
183
+ return candidates;
184
+ }
185
+ function runCoverageIngester(projectRoot) {
186
+ const coveragePath = join(projectRoot, COVERAGE_SUMMARY_PATH);
187
+ let summary;
188
+ try {
189
+ const raw = readFileSync(coveragePath, "utf-8");
190
+ summary = JSON.parse(raw);
191
+ } catch {
192
+ return [];
193
+ }
194
+ const candidates = [];
195
+ for (const [filePath, entry] of Object.entries(summary)) {
196
+ if (filePath === "total") continue;
197
+ const pct = entry?.lines?.pct;
198
+ if (typeof pct !== "number" || pct >= MIN_LINE_COVERAGE_PCT) continue;
199
+ candidates.push({
200
+ source: "test",
201
+ sourceId: filePath,
202
+ title: `[T2-TEST] Increase coverage: ${filePath} (${pct}% lines)`,
203
+ rationale: `File ${filePath} has ${pct}% line coverage (target: ${MIN_LINE_COVERAGE_PCT}%)`,
204
+ weight: TEST_BASE_WEIGHT
205
+ });
206
+ }
207
+ return candidates;
208
+ }
209
+ function runTestIngester(projectRoot) {
210
+ try {
211
+ const gatesCandidates = runGatesIngester(projectRoot);
212
+ const coverageCandidates = runCoverageIngester(projectRoot);
213
+ const seenSourceIds = /* @__PURE__ */ new Set();
214
+ const merged = [];
215
+ for (const candidate of [...gatesCandidates, ...coverageCandidates]) {
216
+ if (seenSourceIds.has(candidate.sourceId)) continue;
217
+ seenSourceIds.add(candidate.sourceId);
218
+ merged.push(candidate);
219
+ }
220
+ return merged;
221
+ } catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ process.stderr.write(`[sentient/test-ingester] WARNING: ${message}
224
+ `);
225
+ return [];
226
+ }
227
+ }
228
+
229
+ // packages/core/src/sentient/proposal-rate-limiter.ts
230
+ var SENTIENT_TIER2_TAG = "sentient-tier2";
231
+ var DEFAULT_DAILY_PROPOSAL_LIMIT = 3;
232
+ var SQLITE_BUSY_CODE = "SQLITE_BUSY";
233
+ function countTodayProposals(nativeDb) {
234
+ if (!nativeDb) return 0;
235
+ const stmt = nativeDb.prepare(`
236
+ SELECT COUNT(*) as cnt
237
+ FROM tasks
238
+ WHERE labels_json LIKE :labelPattern
239
+ AND date(created_at) = date('now')
240
+ AND status IN ('proposed', 'pending', 'active', 'done')
241
+ `);
242
+ const row = stmt.get({ labelPattern: `%${SENTIENT_TIER2_TAG}%` });
243
+ return row?.cnt ?? 0;
244
+ }
245
+ function transactionalInsertProposal(nativeDb, insertSql, insertParams, limit = DEFAULT_DAILY_PROPOSAL_LIMIT) {
246
+ try {
247
+ nativeDb.exec("BEGIN IMMEDIATE");
248
+ } catch (err) {
249
+ const msg = err instanceof Error ? err.message : String(err);
250
+ if (msg.includes(SQLITE_BUSY_CODE)) {
251
+ return { inserted: false, countBeforeInsert: 0, reason: "busy" };
252
+ }
253
+ throw err;
254
+ }
255
+ try {
256
+ const countBeforeInsert = countTodayProposals(nativeDb);
257
+ if (countBeforeInsert >= limit) {
258
+ nativeDb.exec("ROLLBACK");
259
+ return { inserted: false, countBeforeInsert, reason: "rate-limit" };
260
+ }
261
+ const stmt = nativeDb.prepare(insertSql);
262
+ stmt.run(insertParams);
263
+ nativeDb.exec("COMMIT");
264
+ return { inserted: true, countBeforeInsert };
265
+ } catch (err) {
266
+ try {
267
+ nativeDb.exec("ROLLBACK");
268
+ } catch {
269
+ }
270
+ throw err;
271
+ }
272
+ }
273
+
274
+ // packages/core/src/sentient/state.ts
275
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
276
+ import { dirname, join as join2 } from "node:path";
277
+ var SENTIENT_STATE_SCHEMA_VERSION = "1.0";
278
+ var DEFAULT_SENTIENT_STATE = {
279
+ schemaVersion: SENTIENT_STATE_SCHEMA_VERSION,
280
+ pid: null,
281
+ startedAt: null,
282
+ lastTickAt: null,
283
+ killSwitch: false,
284
+ killSwitchReason: null,
285
+ stats: {
286
+ tasksPicked: 0,
287
+ tasksCompleted: 0,
288
+ tasksFailed: 0,
289
+ ticksExecuted: 0,
290
+ ticksKilled: 0
291
+ },
292
+ stuckTasks: {},
293
+ stuckTimestamps: [],
294
+ activeTaskId: null,
295
+ tier2Enabled: false,
296
+ tier2Stats: {
297
+ proposalsGenerated: 0,
298
+ proposalsAccepted: 0,
299
+ proposalsRejected: 0
300
+ }
301
+ };
302
+ async function readSentientState(statePath) {
303
+ try {
304
+ const raw = await readFile(statePath, "utf-8");
305
+ const parsed = JSON.parse(raw);
306
+ return {
307
+ ...DEFAULT_SENTIENT_STATE,
308
+ ...parsed,
309
+ stats: { ...DEFAULT_SENTIENT_STATE.stats, ...parsed.stats ?? {} },
310
+ stuckTasks: parsed.stuckTasks ?? {},
311
+ stuckTimestamps: parsed.stuckTimestamps ?? [],
312
+ tier2Enabled: parsed.tier2Enabled ?? false,
313
+ tier2Stats: { ...DEFAULT_SENTIENT_STATE.tier2Stats, ...parsed.tier2Stats ?? {} }
314
+ };
315
+ } catch {
316
+ return { ...DEFAULT_SENTIENT_STATE };
317
+ }
318
+ }
319
+ async function writeSentientState(statePath, state) {
320
+ const dir = dirname(statePath);
321
+ await mkdir(dir, { recursive: true });
322
+ const tmpPath = join2(dir, `.sentient-state-${process.pid}.tmp`);
323
+ const json = JSON.stringify(state, null, 2);
324
+ await writeFile(tmpPath, json, "utf-8");
325
+ await rename(tmpPath, statePath);
326
+ }
327
+ async function patchSentientState(statePath, patch) {
328
+ const current = await readSentientState(statePath);
329
+ const updated = {
330
+ ...current,
331
+ ...patch,
332
+ stats: { ...current.stats, ...patch.stats ?? {} }
333
+ };
334
+ await writeSentientState(statePath, updated);
335
+ return updated;
336
+ }
337
+ async function incrementStats(statePath, delta) {
338
+ const current = await readSentientState(statePath);
339
+ const nextStats = {
340
+ tasksPicked: current.stats.tasksPicked + (delta.tasksPicked ?? 0),
341
+ tasksCompleted: current.stats.tasksCompleted + (delta.tasksCompleted ?? 0),
342
+ tasksFailed: current.stats.tasksFailed + (delta.tasksFailed ?? 0),
343
+ ticksExecuted: current.stats.ticksExecuted + (delta.ticksExecuted ?? 0),
344
+ ticksKilled: current.stats.ticksKilled + (delta.ticksKilled ?? 0)
345
+ };
346
+ const updated = { ...current, stats: nextStats };
347
+ await writeSentientState(statePath, updated);
348
+ return updated;
349
+ }
350
+
351
+ // packages/core/src/sentient/propose-tick.ts
352
+ var PROPOSAL_TITLE_PATTERN = /^\[T2-(BRAIN|NEXUS|TEST)\]/;
353
+ var TIER2_LABEL = "sentient-tier2";
354
+ function fingerprint(candidate) {
355
+ return `${candidate.source}:${candidate.sourceId}`;
356
+ }
357
+ async function killSwitchActive(statePath) {
358
+ const state = await readSentientState(statePath);
359
+ return state.killSwitch === true;
360
+ }
361
+ async function runProposeTick(options) {
362
+ const { projectRoot, statePath } = options;
363
+ if (await killSwitchActive(statePath)) {
364
+ return { kind: "killed", written: 0, count: 0, detail: "killSwitch active before ingest" };
365
+ }
366
+ const state = await readSentientState(statePath);
367
+ if (!state.tier2Enabled) {
368
+ return {
369
+ kind: "disabled",
370
+ written: 0,
371
+ count: 0,
372
+ detail: "tier2Enabled=false; enable via cleo sentient propose enable"
373
+ };
374
+ }
375
+ let brainDb;
376
+ let nexusDb;
377
+ let tasksNativeDb;
378
+ if (options.brainDb !== void 0) {
379
+ brainDb = options.brainDb;
380
+ } else {
381
+ try {
382
+ const { getBrainDb, getBrainNativeDb } = await import("@cleocode/core/internal");
383
+ await getBrainDb(projectRoot);
384
+ brainDb = getBrainNativeDb();
385
+ } catch {
386
+ brainDb = null;
387
+ }
388
+ }
389
+ if (options.nexusDb !== void 0) {
390
+ nexusDb = options.nexusDb;
391
+ } else {
392
+ try {
393
+ const { getNexusNativeDb } = await import("@cleocode/core/internal");
394
+ nexusDb = getNexusNativeDb();
395
+ } catch {
396
+ nexusDb = null;
397
+ }
398
+ }
399
+ if (options.tasksDb !== void 0) {
400
+ tasksNativeDb = options.tasksDb;
401
+ } else {
402
+ const { getNativeDb, getDb } = await import("@cleocode/core/internal");
403
+ await getDb(projectRoot);
404
+ tasksNativeDb = getNativeDb();
405
+ }
406
+ const [brainCandidates, nexusCandidates, testCandidates] = await Promise.all([
407
+ Promise.resolve(runBrainIngester(brainDb)),
408
+ Promise.resolve(runNexusIngester(nexusDb)),
409
+ Promise.resolve(runTestIngester(projectRoot))
410
+ ]);
411
+ if (await killSwitchActive(statePath)) {
412
+ return {
413
+ kind: "killed",
414
+ written: 0,
415
+ count: 0,
416
+ detail: "killSwitch active after ingest phase"
417
+ };
418
+ }
419
+ const seenFingerprints = /* @__PURE__ */ new Set();
420
+ const merged = [];
421
+ for (const candidate of [...brainCandidates, ...nexusCandidates, ...testCandidates]) {
422
+ if (!PROPOSAL_TITLE_PATTERN.test(candidate.title)) {
423
+ process.stderr.write(
424
+ `[sentient/propose-tick] Rejected candidate with invalid title format: "${candidate.title}"
425
+ `
426
+ );
427
+ continue;
428
+ }
429
+ const fp = fingerprint(candidate);
430
+ if (seenFingerprints.has(fp)) continue;
431
+ seenFingerprints.add(fp);
432
+ merged.push(candidate);
433
+ }
434
+ if (merged.length === 0) {
435
+ return { kind: "no-candidates", written: 0, count: 0, detail: "no candidates from ingesters" };
436
+ }
437
+ merged.sort((a, b) => b.weight - a.weight);
438
+ const currentCount = tasksNativeDb ? countTodayProposals(tasksNativeDb) : 0;
439
+ const slotsRemaining = Math.max(0, DEFAULT_DAILY_PROPOSAL_LIMIT - currentCount);
440
+ if (slotsRemaining === 0) {
441
+ return {
442
+ kind: "rate-limited",
443
+ written: 0,
444
+ count: currentCount,
445
+ detail: `daily limit reached (${currentCount}/${DEFAULT_DAILY_PROPOSAL_LIMIT})`
446
+ };
447
+ }
448
+ const toWrite = merged.slice(0, slotsRemaining);
449
+ if (await killSwitchActive(statePath)) {
450
+ return {
451
+ kind: "killed",
452
+ written: 0,
453
+ count: currentCount,
454
+ detail: "killSwitch active before write phase"
455
+ };
456
+ }
457
+ let written = 0;
458
+ for (const candidate of toWrite) {
459
+ let taskId;
460
+ if (options.allocateTaskId) {
461
+ taskId = await options.allocateTaskId();
462
+ } else {
463
+ const { allocateNextTaskId } = await import("@cleocode/core/internal");
464
+ taskId = await allocateNextTaskId(projectRoot);
465
+ }
466
+ const now = (/* @__PURE__ */ new Date()).toISOString();
467
+ const labels = JSON.stringify([TIER2_LABEL, `source:${candidate.source}`]);
468
+ if (!tasksNativeDb) {
469
+ process.stderr.write("[sentient/propose-tick] tasks DB not available; skipping write\n");
470
+ break;
471
+ }
472
+ const notesJson = JSON.stringify([
473
+ JSON.stringify({
474
+ kind: "proposal-meta",
475
+ proposedBy: "sentient-tier2",
476
+ source: candidate.source,
477
+ sourceId: candidate.sourceId,
478
+ weight: candidate.weight,
479
+ proposedAt: now
480
+ })
481
+ ]);
482
+ const insertSql = `
483
+ INSERT INTO tasks (
484
+ id, title, description, status, priority,
485
+ labels_json, notes_json,
486
+ created_at, updated_at,
487
+ role, scope
488
+ ) VALUES (
489
+ :id, :title, :description, :status, :priority,
490
+ :labelsJson, :notesJson,
491
+ :createdAt, :updatedAt,
492
+ :role, :scope
493
+ )
494
+ `;
495
+ const insertParams = {
496
+ id: taskId,
497
+ title: candidate.title,
498
+ description: candidate.rationale,
499
+ status: "proposed",
500
+ priority: "medium",
501
+ labelsJson: labels,
502
+ notesJson,
503
+ createdAt: now,
504
+ updatedAt: now,
505
+ role: "work",
506
+ scope: "feature"
507
+ };
508
+ try {
509
+ const result = transactionalInsertProposal(
510
+ tasksNativeDb,
511
+ insertSql,
512
+ insertParams,
513
+ DEFAULT_DAILY_PROPOSAL_LIMIT
514
+ );
515
+ if (result.inserted) {
516
+ written++;
517
+ } else if (result.reason === "rate-limit") {
518
+ break;
519
+ }
520
+ } catch (err) {
521
+ const message = err instanceof Error ? err.message : String(err);
522
+ process.stderr.write(`[sentient/propose-tick] INSERT failed for ${taskId}: ${message}
523
+ `);
524
+ }
525
+ }
526
+ if (written > 0) {
527
+ const latestState = await readSentientState(statePath);
528
+ await patchSentientState(statePath, {
529
+ tier2Stats: {
530
+ ...latestState.tier2Stats,
531
+ proposalsGenerated: latestState.tier2Stats.proposalsGenerated + written
532
+ }
533
+ });
534
+ }
535
+ const finalCount = tasksNativeDb ? countTodayProposals(tasksNativeDb) : currentCount + written;
536
+ if (written === 0) {
537
+ return {
538
+ kind: "no-candidates",
539
+ written: 0,
540
+ count: finalCount,
541
+ detail: "candidates available but none written (rate limit or DB unavailable)"
542
+ };
543
+ }
544
+ return {
545
+ kind: "wrote",
546
+ written,
547
+ count: finalCount,
548
+ detail: `wrote ${written} proposal(s) (${finalCount}/${DEFAULT_DAILY_PROPOSAL_LIMIT} today)`
549
+ };
550
+ }
551
+ async function safeRunProposeTick(options) {
552
+ try {
553
+ return await runProposeTick(options);
554
+ } catch (err) {
555
+ const message = err instanceof Error ? err.message : String(err);
556
+ return {
557
+ kind: "error",
558
+ written: 0,
559
+ count: 0,
560
+ detail: `propose tick threw: ${message}`
561
+ };
562
+ }
563
+ }
564
+
565
+ // packages/core/src/sentient/tick.ts
566
+ import { spawn } from "node:child_process";
567
+ var DREAM_VOLUME_THRESHOLD_DEFAULT = 50;
568
+ var DREAM_IDLE_TICKS_DEFAULT = 5;
569
+ var DEFAULT_ADAPTER = "claude-code";
570
+ var RETRY_BACKOFF_MS = [3e4, 3e5, 18e5];
571
+ var MAX_TASK_ATTEMPTS = RETRY_BACKOFF_MS.length;
572
+ var SELF_PAUSE_STUCK_THRESHOLD = 5;
573
+ var SELF_PAUSE_WINDOW_MS = 60 * 60 * 1e3;
574
+ var SELF_PAUSE_REASON = "self-pause: 5 stuck tasks in 1 hour";
575
+ var SPAWN_TIMEOUT_MS = 30 * 60 * 1e3;
576
+ async function killSwitchActive2(statePath) {
577
+ const state = await readSentientState(statePath);
578
+ return state.killSwitch === true;
579
+ }
580
+ function pruneStuckWindow(timestamps, now) {
581
+ const cutoff = now - SELF_PAUSE_WINDOW_MS;
582
+ return timestamps.filter((t) => t >= cutoff);
583
+ }
584
+ async function defaultPickTask(projectRoot) {
585
+ const { Cleo } = await import("@cleocode/core/sdk");
586
+ const { getReadyTasks } = await import("@cleocode/core/tasks");
587
+ const cleo = await Cleo.init(projectRoot);
588
+ const pending = await cleo.tasks.find({ status: "pending", limit: 500 });
589
+ const candidates = Array.isArray(pending?.data?.tasks) ? pending.data.tasks : [];
590
+ if (candidates.length === 0) return null;
591
+ const ready = getReadyTasks(candidates);
592
+ if (ready.length === 0) return null;
593
+ ready.sort((a, b) => a.id.localeCompare(b.id));
594
+ return ready[0];
595
+ }
596
+ function defaultSpawn(taskId, adapter, projectRoot) {
597
+ return new Promise((resolve) => {
598
+ const args = ["orchestrate", "spawn", taskId, "--adapter", adapter];
599
+ const child = spawn("cleo", args, {
600
+ cwd: projectRoot,
601
+ env: { ...process.env, CLEO_SENTIENT_SPAWN: "1" },
602
+ stdio: ["ignore", "pipe", "pipe"]
603
+ });
604
+ let stdout = "";
605
+ let stderr = "";
606
+ child.stdout?.on("data", (chunk) => {
607
+ stdout += chunk.toString("utf-8");
608
+ });
609
+ child.stderr?.on("data", (chunk) => {
610
+ stderr += chunk.toString("utf-8");
611
+ });
612
+ const timer = setTimeout(() => {
613
+ child.kill("SIGTERM");
614
+ }, SPAWN_TIMEOUT_MS);
615
+ child.on("error", (err) => {
616
+ clearTimeout(timer);
617
+ resolve({
618
+ exitCode: 1,
619
+ stdout,
620
+ stderr: stderr + `
621
+ [sentient] spawn error: ${err.message}`
622
+ });
623
+ });
624
+ child.on("exit", (code) => {
625
+ clearTimeout(timer);
626
+ resolve({
627
+ exitCode: code ?? 1,
628
+ stdout: stdout.slice(-4e3),
629
+ stderr: stderr.slice(-4e3)
630
+ });
631
+ });
632
+ });
633
+ }
634
+ async function writeSuccessReceipt(projectRoot, taskId, exitCode) {
635
+ try {
636
+ const { Cleo } = await import("@cleocode/core/sdk");
637
+ const cleo = await Cleo.init(projectRoot);
638
+ await cleo.memory.observe({
639
+ text: `sentient-tier1: task ${taskId} completed successfully (exit=${exitCode})`,
640
+ title: `sentient-receipt: ${taskId}`
641
+ });
642
+ } catch {
643
+ }
644
+ }
645
+ var consecutiveIdleTicks = 0;
646
+ async function maybeTriggerDream(projectRoot, opts, pickedTask) {
647
+ const volumeThreshold = opts.dreamVolumeThreshold ?? DREAM_VOLUME_THRESHOLD_DEFAULT;
648
+ const idleTicksThreshold = opts.dreamIdleTicks ?? DREAM_IDLE_TICKS_DEFAULT;
649
+ if (volumeThreshold <= 0 && idleTicksThreshold <= 0) return;
650
+ if (pickedTask) {
651
+ consecutiveIdleTicks = 0;
652
+ } else {
653
+ consecutiveIdleTicks += 1;
654
+ }
655
+ const dreamer = opts.checkAndDream ?? (async (root, dreamerOpts) => {
656
+ const { checkAndDream } = await import("@cleocode/core/internal");
657
+ return checkAndDream(root, dreamerOpts);
658
+ });
659
+ try {
660
+ await dreamer(projectRoot, {
661
+ volumeThreshold: volumeThreshold > 0 ? volumeThreshold : void 0,
662
+ inline: false
663
+ }).catch((err) => {
664
+ console.warn("[sentient/tick] dream trigger error:", err);
665
+ });
666
+ } catch (err) {
667
+ console.warn("[sentient/tick] dream trigger threw:", err);
668
+ }
669
+ }
670
+ async function writeFailureReceipt(projectRoot, taskId, attempt, exitCode, reason) {
671
+ try {
672
+ const { Cleo } = await import("@cleocode/core/sdk");
673
+ const cleo = await Cleo.init(projectRoot);
674
+ await cleo.memory.observe({
675
+ text: `sentient-tier1: task ${taskId} failed (attempt=${attempt}/${MAX_TASK_ATTEMPTS}, exit=${exitCode}). reason=${reason.slice(0, 500)}`,
676
+ title: `sentient-failure: ${taskId}`
677
+ });
678
+ } catch {
679
+ }
680
+ }
681
+ async function runTick(options) {
682
+ const { projectRoot, statePath } = options;
683
+ const adapter = options.adapter ?? DEFAULT_ADAPTER;
684
+ const now = Date.now();
685
+ if (await killSwitchActive2(statePath)) {
686
+ await incrementStats(statePath, { ticksKilled: 1 });
687
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
688
+ return { kind: "killed", taskId: null, detail: "killSwitch active before pick" };
689
+ }
690
+ const picker = options.pickTask ?? defaultPickTask;
691
+ let task;
692
+ try {
693
+ task = await picker(projectRoot);
694
+ } catch (err) {
695
+ const message = err instanceof Error ? err.message : String(err);
696
+ await incrementStats(statePath, { ticksExecuted: 1 });
697
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
698
+ return { kind: "error", taskId: null, detail: `picker threw: ${message}` };
699
+ }
700
+ if (task === null) {
701
+ await incrementStats(statePath, { ticksExecuted: 1 });
702
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
703
+ return { kind: "no-task", taskId: null, detail: "no unblocked tasks available" };
704
+ }
705
+ const preSpawnState = await readSentientState(statePath);
706
+ const existingStuck = preSpawnState.stuckTasks[task.id];
707
+ if (existingStuck && existingStuck.nextRetryAt > now) {
708
+ await incrementStats(statePath, { ticksExecuted: 1 });
709
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
710
+ return {
711
+ kind: "backoff",
712
+ taskId: task.id,
713
+ detail: `task ${task.id} in backoff until ${new Date(existingStuck.nextRetryAt).toISOString()}`
714
+ };
715
+ }
716
+ if (await killSwitchActive2(statePath)) {
717
+ await incrementStats(statePath, { ticksKilled: 1 });
718
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
719
+ return { kind: "killed", taskId: task.id, detail: "killSwitch active before spawn" };
720
+ }
721
+ await incrementStats(statePath, { tasksPicked: 1 });
722
+ await patchSentientState(statePath, { activeTaskId: task.id });
723
+ let spawnResult;
724
+ if (options.dryRun === true) {
725
+ spawnResult = {
726
+ exitCode: 0,
727
+ stdout: "[dry-run] spawn skipped",
728
+ stderr: ""
729
+ };
730
+ } else {
731
+ try {
732
+ const spawner = options.spawn ?? ((tid, adp) => defaultSpawn(tid, adp, projectRoot));
733
+ spawnResult = await spawner(task.id, adapter);
734
+ } catch (err) {
735
+ const message = err instanceof Error ? err.message : String(err);
736
+ spawnResult = { exitCode: 1, stdout: "", stderr: `spawn threw: ${message}` };
737
+ }
738
+ }
739
+ if (await killSwitchActive2(statePath)) {
740
+ await incrementStats(statePath, { ticksKilled: 1 });
741
+ await patchSentientState(statePath, {
742
+ lastTickAt: new Date(Date.now()).toISOString(),
743
+ activeTaskId: null
744
+ });
745
+ return {
746
+ kind: "killed",
747
+ taskId: task.id,
748
+ detail: "killSwitch active after spawn; result not recorded"
749
+ };
750
+ }
751
+ if (spawnResult.exitCode === 0) {
752
+ await writeSuccessReceipt(projectRoot, task.id, spawnResult.exitCode);
753
+ const post2 = await readSentientState(statePath);
754
+ const { [task.id]: _removed, ...rest } = post2.stuckTasks;
755
+ void _removed;
756
+ await patchSentientState(statePath, {
757
+ stuckTasks: rest,
758
+ activeTaskId: null,
759
+ lastTickAt: new Date(Date.now()).toISOString()
760
+ });
761
+ await incrementStats(statePath, { tasksCompleted: 1, ticksExecuted: 1 });
762
+ return {
763
+ kind: "success",
764
+ taskId: task.id,
765
+ detail: `task ${task.id} completed (exit=0)`
766
+ };
767
+ }
768
+ const currentAttempts = existingStuck?.attempts ?? 0;
769
+ const nextAttempts = currentAttempts + 1;
770
+ const failureReason = spawnResult.stderr.slice(-500) || `exit=${spawnResult.exitCode}`;
771
+ await writeFailureReceipt(
772
+ projectRoot,
773
+ task.id,
774
+ nextAttempts,
775
+ spawnResult.exitCode,
776
+ failureReason
777
+ );
778
+ await incrementStats(statePath, { tasksFailed: 1, ticksExecuted: 1 });
779
+ if (nextAttempts >= MAX_TASK_ATTEMPTS) {
780
+ const windowed = pruneStuckWindow(preSpawnState.stuckTimestamps, now);
781
+ windowed.push(now);
782
+ const stuckRecord2 = {
783
+ attempts: nextAttempts,
784
+ lastFailureAt: new Date(now).toISOString(),
785
+ nextRetryAt: Number.MAX_SAFE_INTEGER,
786
+ // owner-only release
787
+ lastReason: failureReason
788
+ };
789
+ const post2 = await readSentientState(statePath);
790
+ const updatedStuckTasks = {
791
+ ...post2.stuckTasks,
792
+ [task.id]: stuckRecord2
793
+ };
794
+ const shouldSelfPause = windowed.length >= SELF_PAUSE_STUCK_THRESHOLD;
795
+ await patchSentientState(statePath, {
796
+ stuckTasks: updatedStuckTasks,
797
+ stuckTimestamps: windowed,
798
+ activeTaskId: null,
799
+ lastTickAt: new Date(now).toISOString(),
800
+ ...shouldSelfPause ? { killSwitch: true, killSwitchReason: SELF_PAUSE_REASON } : {}
801
+ });
802
+ if (shouldSelfPause) {
803
+ return {
804
+ kind: "self-paused",
805
+ taskId: task.id,
806
+ detail: `task ${task.id} is stuck; self-pause fired (${windowed.length}/${SELF_PAUSE_STUCK_THRESHOLD} stucks in window)`
807
+ };
808
+ }
809
+ return {
810
+ kind: "stuck",
811
+ taskId: task.id,
812
+ detail: `task ${task.id} stuck after ${nextAttempts} attempts; owner must re-enable via \`cleo sentient resume\``
813
+ };
814
+ }
815
+ const backoff = RETRY_BACKOFF_MS[nextAttempts - 1] ?? RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1];
816
+ const stuckRecord = {
817
+ attempts: nextAttempts,
818
+ lastFailureAt: new Date(now).toISOString(),
819
+ nextRetryAt: now + backoff,
820
+ lastReason: failureReason
821
+ };
822
+ const post = await readSentientState(statePath);
823
+ await patchSentientState(statePath, {
824
+ stuckTasks: { ...post.stuckTasks, [task.id]: stuckRecord },
825
+ activeTaskId: null,
826
+ lastTickAt: new Date(now).toISOString()
827
+ });
828
+ return {
829
+ kind: "failure",
830
+ taskId: task.id,
831
+ detail: `task ${task.id} failed (attempt=${nextAttempts}/${MAX_TASK_ATTEMPTS}); retry scheduled at ${new Date(now + backoff).toISOString()}`
832
+ };
833
+ }
834
+ async function safeRunTick(options) {
835
+ let outcome;
836
+ try {
837
+ outcome = await runTick(options);
838
+ } catch (err) {
839
+ const message = err instanceof Error ? err.message : String(err);
840
+ try {
841
+ await incrementStats(options.statePath, { ticksExecuted: 1 });
842
+ } catch {
843
+ }
844
+ outcome = { kind: "error", taskId: null, detail: `tick threw: ${message}` };
845
+ }
846
+ const pickedTask = outcome.kind !== "no-task" && outcome.kind !== "killed" && outcome.kind !== "error" && outcome.taskId !== null;
847
+ await maybeTriggerDream(options.projectRoot, options, pickedTask).catch(() => {
848
+ });
849
+ return outcome;
850
+ }
851
+
852
+ // packages/core/src/sentient/daemon.ts
853
+ var SENTIENT_STATE_FILE = ".cleo/sentient-state.json";
854
+ var SENTIENT_LOCK_FILE = ".cleo/sentient.lock";
855
+ var SENTIENT_CRON_EXPR = "*/5 * * * *";
856
+ var SENTIENT_PROPOSE_CRON_EXPR = "0 */2 * * *";
857
+ var SENTIENT_LOG_DIR = ".cleo/logs";
858
+ var SENTIENT_LOG = "sentient.log";
859
+ var SENTIENT_ERR = "sentient.err";
860
+ async function acquireLock(lockPath) {
861
+ await mkdir2(join3(lockPath, ".."), { recursive: true });
862
+ try {
863
+ const handle = await fsOpen(
864
+ lockPath,
865
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR,
866
+ 420
867
+ );
868
+ await handle.writeFile(String(process.pid), "utf-8");
869
+ return { path: lockPath, handle };
870
+ } catch (err) {
871
+ const code = err.code;
872
+ if (code !== "EEXIST") throw err;
873
+ }
874
+ let existing = null;
875
+ try {
876
+ existing = await fsOpen(lockPath, fsConstants.O_RDWR);
877
+ const buf = await existing.readFile({ encoding: "utf-8" });
878
+ const recordedPid = Number.parseInt(buf.trim(), 10);
879
+ if (Number.isFinite(recordedPid) && recordedPid > 0) {
880
+ try {
881
+ process.kill(recordedPid, 0);
882
+ await existing.close();
883
+ return null;
884
+ } catch {
885
+ }
886
+ }
887
+ await existing.truncate(0);
888
+ const pidBytes = Buffer.from(String(process.pid), "utf-8");
889
+ await existing.write(pidBytes, 0, pidBytes.length, 0);
890
+ return { path: lockPath, handle: existing };
891
+ } catch (err) {
892
+ if (existing) {
893
+ try {
894
+ await existing.close();
895
+ } catch {
896
+ }
897
+ }
898
+ throw err;
899
+ }
900
+ }
901
+ async function releaseLock(lock) {
902
+ try {
903
+ await lock.handle.close();
904
+ } catch {
905
+ }
906
+ }
907
+ async function bootstrapDaemon(projectRoot) {
908
+ const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
909
+ const lockPath = join3(projectRoot, SENTIENT_LOCK_FILE);
910
+ const lock = await acquireLock(lockPath);
911
+ if (!lock) {
912
+ process.stderr.write(`[CLEO SENTIENT] lock acquisition failed \u2014 another daemon is running
913
+ `);
914
+ process.exit(2);
915
+ }
916
+ await patchSentientState(statePath, {
917
+ pid: process.pid,
918
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
919
+ // Clear killSwitch on boot only if owner did not explicitly leave it set.
920
+ // We preserve it here: re-starting a killed daemon must not silently
921
+ // resume. Owner explicitly clears via `cleo sentient resume`.
922
+ });
923
+ let watcher = null;
924
+ try {
925
+ watcher = watch(statePath, { persistent: false }, () => {
926
+ });
927
+ } catch {
928
+ watcher = null;
929
+ }
930
+ const shutdown = async (reason) => {
931
+ try {
932
+ watcher?.close();
933
+ } catch {
934
+ }
935
+ try {
936
+ await patchSentientState(statePath, {
937
+ pid: null,
938
+ killSwitchReason: reason
939
+ });
940
+ } catch {
941
+ }
942
+ try {
943
+ await releaseLock(lock);
944
+ } catch {
945
+ }
946
+ process.exit(0);
947
+ };
948
+ process.on("SIGTERM", () => {
949
+ void shutdown("SIGTERM");
950
+ });
951
+ process.on("SIGINT", () => {
952
+ void shutdown("SIGINT");
953
+ });
954
+ const tickOptions = { projectRoot, statePath };
955
+ const outcome = await safeRunTick(tickOptions);
956
+ process.stdout.write(
957
+ `[CLEO SENTIENT] boot tick: ${outcome.kind} (task=${outcome.taskId ?? "n/a"}) ${outcome.detail}
958
+ `
959
+ );
960
+ cron.schedule(
961
+ SENTIENT_CRON_EXPR,
962
+ async () => {
963
+ const result = await safeRunTick(tickOptions);
964
+ process.stdout.write(
965
+ `[CLEO SENTIENT] tick: ${result.kind} (task=${result.taskId ?? "n/a"}) ${result.detail}
966
+ `
967
+ );
968
+ },
969
+ {
970
+ timezone: "UTC",
971
+ noOverlap: true,
972
+ name: "cleo-sentient"
973
+ }
974
+ );
975
+ const proposeOptions = { projectRoot, statePath };
976
+ cron.schedule(
977
+ SENTIENT_PROPOSE_CRON_EXPR,
978
+ async () => {
979
+ const state = await readSentientState(statePath);
980
+ if (!state.tier2Enabled) return;
981
+ const result = await safeRunProposeTick(proposeOptions);
982
+ process.stdout.write(
983
+ `[CLEO SENTIENT T2] propose: ${result.kind} (written=${result.written}, count=${result.count}) ${result.detail}
984
+ `
985
+ );
986
+ },
987
+ {
988
+ timezone: "UTC",
989
+ noOverlap: true,
990
+ name: "cleo-sentient-propose"
991
+ }
992
+ );
993
+ }
994
+ async function spawnSentientDaemon(projectRoot) {
995
+ const logsDir = join3(projectRoot, SENTIENT_LOG_DIR);
996
+ await mkdir2(logsDir, { recursive: true });
997
+ const logPath = join3(logsDir, SENTIENT_LOG);
998
+ const errPath = join3(logsDir, SENTIENT_ERR);
999
+ const outStream = createWriteStream(logPath, { flags: "a" });
1000
+ const errStream = createWriteStream(errPath, { flags: "a" });
1001
+ const daemonEntry = join3(fileURLToPath(import.meta.url), "..", "daemon-entry.js");
1002
+ const child = spawn2(process.execPath, [daemonEntry, projectRoot], {
1003
+ detached: true,
1004
+ stdio: ["ignore", outStream, errStream],
1005
+ env: { ...process.env, CLEO_SENTIENT_DAEMON: "1" }
1006
+ });
1007
+ child.unref();
1008
+ const pid = child.pid ?? 0;
1009
+ const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
1010
+ await patchSentientState(statePath, {
1011
+ pid,
1012
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1013
+ });
1014
+ return { pid, statePath, logPath };
1015
+ }
1016
+ async function stopSentientDaemon(projectRoot, reason = "cleo sentient stop") {
1017
+ const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
1018
+ const state = await readSentientState(statePath);
1019
+ await patchSentientState(statePath, {
1020
+ killSwitch: true,
1021
+ killSwitchReason: reason
1022
+ });
1023
+ const pid = state.pid;
1024
+ if (!pid) {
1025
+ return {
1026
+ stopped: false,
1027
+ pid: null,
1028
+ reason: "killSwitch set; no daemon pid recorded (no active process to signal)"
1029
+ };
1030
+ }
1031
+ try {
1032
+ process.kill(pid, 0);
1033
+ } catch {
1034
+ await patchSentientState(statePath, { pid: null });
1035
+ return {
1036
+ stopped: true,
1037
+ pid,
1038
+ reason: `killSwitch set; daemon pid ${pid} was already dead (cleared)`
1039
+ };
1040
+ }
1041
+ try {
1042
+ process.kill(pid, "SIGTERM");
1043
+ return { stopped: true, pid, reason: `killSwitch set + SIGTERM delivered to pid ${pid}` };
1044
+ } catch (err) {
1045
+ const message = err instanceof Error ? err.message : String(err);
1046
+ return {
1047
+ stopped: false,
1048
+ pid,
1049
+ reason: `killSwitch set but SIGTERM failed: ${message}`
1050
+ };
1051
+ }
1052
+ }
1053
+ async function resumeSentientDaemon(projectRoot) {
1054
+ const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
1055
+ return patchSentientState(statePath, {
1056
+ killSwitch: false,
1057
+ killSwitchReason: null
1058
+ });
1059
+ }
1060
+ async function getSentientDaemonStatus(projectRoot) {
1061
+ const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
1062
+ const state = await readSentientState(statePath);
1063
+ let running = false;
1064
+ if (state.pid) {
1065
+ try {
1066
+ process.kill(state.pid, 0);
1067
+ running = true;
1068
+ } catch {
1069
+ running = false;
1070
+ }
1071
+ }
1072
+ return {
1073
+ running,
1074
+ pid: running ? state.pid : null,
1075
+ startedAt: state.startedAt,
1076
+ lastTickAt: state.lastTickAt,
1077
+ killSwitch: state.killSwitch,
1078
+ killSwitchReason: state.killSwitchReason,
1079
+ stats: state.stats,
1080
+ stuckCount: Object.keys(state.stuckTasks).length,
1081
+ activeTaskId: state.activeTaskId
1082
+ };
1083
+ }
1084
+ export {
1085
+ SENTIENT_CRON_EXPR,
1086
+ SENTIENT_ERR,
1087
+ SENTIENT_LOCK_FILE,
1088
+ SENTIENT_LOG,
1089
+ SENTIENT_LOG_DIR,
1090
+ SENTIENT_PROPOSE_CRON_EXPR,
1091
+ SENTIENT_STATE_FILE,
1092
+ acquireLock,
1093
+ bootstrapDaemon,
1094
+ getSentientDaemonStatus,
1095
+ releaseLock,
1096
+ resumeSentientDaemon,
1097
+ spawnSentientDaemon,
1098
+ stopSentientDaemon
1099
+ };
1100
+ //# sourceMappingURL=daemon.js.map