@cleocode/core 2026.4.98 → 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.
Files changed (85) hide show
  1. package/dist/gc/daemon-entry.d.ts +15 -0
  2. package/dist/gc/daemon-entry.d.ts.map +1 -0
  3. package/dist/gc/daemon.d.ts +71 -0
  4. package/dist/gc/daemon.d.ts.map +1 -0
  5. package/dist/gc/daemon.js +481 -0
  6. package/dist/gc/daemon.js.map +7 -0
  7. package/dist/gc/index.d.ts +14 -0
  8. package/dist/gc/index.d.ts.map +1 -0
  9. package/dist/gc/index.js +669 -0
  10. package/dist/gc/index.js.map +7 -0
  11. package/dist/gc/runner.d.ts +132 -0
  12. package/dist/gc/runner.d.ts.map +1 -0
  13. package/dist/gc/runner.js +360 -0
  14. package/dist/gc/runner.js.map +7 -0
  15. package/dist/gc/state.d.ts +94 -0
  16. package/dist/gc/state.d.ts.map +1 -0
  17. package/dist/gc/state.js +49 -0
  18. package/dist/gc/state.js.map +7 -0
  19. package/dist/gc/transcript.d.ts +130 -0
  20. package/dist/gc/transcript.d.ts.map +1 -0
  21. package/dist/gc/transcript.js +209 -0
  22. package/dist/gc/transcript.js.map +7 -0
  23. package/dist/memory/brain-backfill.js +14643 -0
  24. package/dist/memory/brain-backfill.js.map +7 -0
  25. package/dist/memory/precompact-flush.js +47725 -0
  26. package/dist/memory/precompact-flush.js.map +7 -0
  27. package/dist/sentient/daemon-entry.d.ts +11 -0
  28. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  29. package/dist/sentient/daemon.d.ts +160 -0
  30. package/dist/sentient/daemon.d.ts.map +1 -0
  31. package/dist/sentient/daemon.js +1100 -0
  32. package/dist/sentient/daemon.js.map +7 -0
  33. package/dist/sentient/index.d.ts +18 -0
  34. package/dist/sentient/index.d.ts.map +1 -0
  35. package/dist/sentient/index.js +1162 -0
  36. package/dist/sentient/index.js.map +7 -0
  37. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  38. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  39. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  40. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  41. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  42. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  43. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  44. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  45. package/dist/sentient/propose-tick.d.ts +105 -0
  46. package/dist/sentient/propose-tick.d.ts.map +1 -0
  47. package/dist/sentient/propose-tick.js +549 -0
  48. package/dist/sentient/propose-tick.js.map +7 -0
  49. package/dist/sentient/state.d.ts +143 -0
  50. package/dist/sentient/state.d.ts.map +1 -0
  51. package/dist/sentient/state.js +85 -0
  52. package/dist/sentient/state.js.map +7 -0
  53. package/dist/sentient/tick.d.ts +193 -0
  54. package/dist/sentient/tick.d.ts.map +1 -0
  55. package/dist/sentient/tick.js +396 -0
  56. package/dist/sentient/tick.js.map +7 -0
  57. package/dist/system/platform-paths.js +36 -0
  58. package/dist/system/platform-paths.js.map +7 -0
  59. package/package.json +76 -8
  60. package/src/gc/__tests__/runner.test.ts +367 -0
  61. package/src/gc/__tests__/state.test.ts +169 -0
  62. package/src/gc/__tests__/transcript.test.ts +371 -0
  63. package/src/gc/daemon-entry.ts +26 -0
  64. package/src/gc/daemon.ts +251 -0
  65. package/src/gc/index.ts +14 -0
  66. package/src/gc/runner.ts +378 -0
  67. package/src/gc/state.ts +140 -0
  68. package/src/gc/transcript.ts +380 -0
  69. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  70. package/src/sentient/__tests__/daemon.test.ts +472 -0
  71. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  72. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  73. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  74. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  75. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  76. package/src/sentient/daemon-entry.ts +20 -0
  77. package/src/sentient/daemon.ts +471 -0
  78. package/src/sentient/index.ts +18 -0
  79. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  80. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  81. package/src/sentient/ingesters/test-ingester.ts +205 -0
  82. package/src/sentient/proposal-rate-limiter.ts +172 -0
  83. package/src/sentient/propose-tick.ts +415 -0
  84. package/src/sentient/state.ts +229 -0
  85. package/src/sentient/tick.ts +688 -0
@@ -0,0 +1,396 @@
1
+ // packages/core/src/sentient/tick.ts
2
+ import { spawn } from "node:child_process";
3
+
4
+ // packages/core/src/sentient/state.ts
5
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ var SENTIENT_STATE_SCHEMA_VERSION = "1.0";
8
+ var DEFAULT_SENTIENT_STATE = {
9
+ schemaVersion: SENTIENT_STATE_SCHEMA_VERSION,
10
+ pid: null,
11
+ startedAt: null,
12
+ lastTickAt: null,
13
+ killSwitch: false,
14
+ killSwitchReason: null,
15
+ stats: {
16
+ tasksPicked: 0,
17
+ tasksCompleted: 0,
18
+ tasksFailed: 0,
19
+ ticksExecuted: 0,
20
+ ticksKilled: 0
21
+ },
22
+ stuckTasks: {},
23
+ stuckTimestamps: [],
24
+ activeTaskId: null,
25
+ tier2Enabled: false,
26
+ tier2Stats: {
27
+ proposalsGenerated: 0,
28
+ proposalsAccepted: 0,
29
+ proposalsRejected: 0
30
+ }
31
+ };
32
+ async function readSentientState(statePath) {
33
+ try {
34
+ const raw = await readFile(statePath, "utf-8");
35
+ const parsed = JSON.parse(raw);
36
+ return {
37
+ ...DEFAULT_SENTIENT_STATE,
38
+ ...parsed,
39
+ stats: { ...DEFAULT_SENTIENT_STATE.stats, ...parsed.stats ?? {} },
40
+ stuckTasks: parsed.stuckTasks ?? {},
41
+ stuckTimestamps: parsed.stuckTimestamps ?? [],
42
+ tier2Enabled: parsed.tier2Enabled ?? false,
43
+ tier2Stats: { ...DEFAULT_SENTIENT_STATE.tier2Stats, ...parsed.tier2Stats ?? {} }
44
+ };
45
+ } catch {
46
+ return { ...DEFAULT_SENTIENT_STATE };
47
+ }
48
+ }
49
+ async function writeSentientState(statePath, state) {
50
+ const dir = dirname(statePath);
51
+ await mkdir(dir, { recursive: true });
52
+ const tmpPath = join(dir, `.sentient-state-${process.pid}.tmp`);
53
+ const json = JSON.stringify(state, null, 2);
54
+ await writeFile(tmpPath, json, "utf-8");
55
+ await rename(tmpPath, statePath);
56
+ }
57
+ async function patchSentientState(statePath, patch) {
58
+ const current = await readSentientState(statePath);
59
+ const updated = {
60
+ ...current,
61
+ ...patch,
62
+ stats: { ...current.stats, ...patch.stats ?? {} }
63
+ };
64
+ await writeSentientState(statePath, updated);
65
+ return updated;
66
+ }
67
+ async function incrementStats(statePath, delta) {
68
+ const current = await readSentientState(statePath);
69
+ const nextStats = {
70
+ tasksPicked: current.stats.tasksPicked + (delta.tasksPicked ?? 0),
71
+ tasksCompleted: current.stats.tasksCompleted + (delta.tasksCompleted ?? 0),
72
+ tasksFailed: current.stats.tasksFailed + (delta.tasksFailed ?? 0),
73
+ ticksExecuted: current.stats.ticksExecuted + (delta.ticksExecuted ?? 0),
74
+ ticksKilled: current.stats.ticksKilled + (delta.ticksKilled ?? 0)
75
+ };
76
+ const updated = { ...current, stats: nextStats };
77
+ await writeSentientState(statePath, updated);
78
+ return updated;
79
+ }
80
+
81
+ // packages/core/src/sentient/tick.ts
82
+ var DREAM_VOLUME_THRESHOLD_DEFAULT = 50;
83
+ var DREAM_IDLE_TICKS_DEFAULT = 5;
84
+ var DEFAULT_ADAPTER = "claude-code";
85
+ var RETRY_BACKOFF_MS = [3e4, 3e5, 18e5];
86
+ var MAX_TASK_ATTEMPTS = RETRY_BACKOFF_MS.length;
87
+ var SELF_PAUSE_STUCK_THRESHOLD = 5;
88
+ var SELF_PAUSE_WINDOW_MS = 60 * 60 * 1e3;
89
+ var SELF_PAUSE_REASON = "self-pause: 5 stuck tasks in 1 hour";
90
+ var SPAWN_TIMEOUT_MS = 30 * 60 * 1e3;
91
+ async function killSwitchActive(statePath) {
92
+ const state = await readSentientState(statePath);
93
+ return state.killSwitch === true;
94
+ }
95
+ function pruneStuckWindow(timestamps, now) {
96
+ const cutoff = now - SELF_PAUSE_WINDOW_MS;
97
+ return timestamps.filter((t) => t >= cutoff);
98
+ }
99
+ async function defaultPickTask(projectRoot) {
100
+ const { Cleo } = await import("@cleocode/core/sdk");
101
+ const { getReadyTasks } = await import("@cleocode/core/tasks");
102
+ const cleo = await Cleo.init(projectRoot);
103
+ const pending = await cleo.tasks.find({ status: "pending", limit: 500 });
104
+ const candidates = Array.isArray(pending?.data?.tasks) ? pending.data.tasks : [];
105
+ if (candidates.length === 0) return null;
106
+ const ready = getReadyTasks(candidates);
107
+ if (ready.length === 0) return null;
108
+ ready.sort((a, b) => a.id.localeCompare(b.id));
109
+ return ready[0];
110
+ }
111
+ function defaultSpawn(taskId, adapter, projectRoot) {
112
+ return new Promise((resolve) => {
113
+ const args = ["orchestrate", "spawn", taskId, "--adapter", adapter];
114
+ const child = spawn("cleo", args, {
115
+ cwd: projectRoot,
116
+ env: { ...process.env, CLEO_SENTIENT_SPAWN: "1" },
117
+ stdio: ["ignore", "pipe", "pipe"]
118
+ });
119
+ let stdout = "";
120
+ let stderr = "";
121
+ child.stdout?.on("data", (chunk) => {
122
+ stdout += chunk.toString("utf-8");
123
+ });
124
+ child.stderr?.on("data", (chunk) => {
125
+ stderr += chunk.toString("utf-8");
126
+ });
127
+ const timer = setTimeout(() => {
128
+ child.kill("SIGTERM");
129
+ }, SPAWN_TIMEOUT_MS);
130
+ child.on("error", (err) => {
131
+ clearTimeout(timer);
132
+ resolve({
133
+ exitCode: 1,
134
+ stdout,
135
+ stderr: stderr + `
136
+ [sentient] spawn error: ${err.message}`
137
+ });
138
+ });
139
+ child.on("exit", (code) => {
140
+ clearTimeout(timer);
141
+ resolve({
142
+ exitCode: code ?? 1,
143
+ stdout: stdout.slice(-4e3),
144
+ stderr: stderr.slice(-4e3)
145
+ });
146
+ });
147
+ });
148
+ }
149
+ async function writeSuccessReceipt(projectRoot, taskId, exitCode) {
150
+ try {
151
+ const { Cleo } = await import("@cleocode/core/sdk");
152
+ const cleo = await Cleo.init(projectRoot);
153
+ await cleo.memory.observe({
154
+ text: `sentient-tier1: task ${taskId} completed successfully (exit=${exitCode})`,
155
+ title: `sentient-receipt: ${taskId}`
156
+ });
157
+ } catch {
158
+ }
159
+ }
160
+ var consecutiveIdleTicks = 0;
161
+ async function maybeTriggerDream(projectRoot, opts, pickedTask) {
162
+ const volumeThreshold = opts.dreamVolumeThreshold ?? DREAM_VOLUME_THRESHOLD_DEFAULT;
163
+ const idleTicksThreshold = opts.dreamIdleTicks ?? DREAM_IDLE_TICKS_DEFAULT;
164
+ if (volumeThreshold <= 0 && idleTicksThreshold <= 0) return;
165
+ if (pickedTask) {
166
+ consecutiveIdleTicks = 0;
167
+ } else {
168
+ consecutiveIdleTicks += 1;
169
+ }
170
+ const dreamer = opts.checkAndDream ?? (async (root, dreamerOpts) => {
171
+ const { checkAndDream } = await import("@cleocode/core/internal");
172
+ return checkAndDream(root, dreamerOpts);
173
+ });
174
+ try {
175
+ await dreamer(projectRoot, {
176
+ volumeThreshold: volumeThreshold > 0 ? volumeThreshold : void 0,
177
+ inline: false
178
+ }).catch((err) => {
179
+ console.warn("[sentient/tick] dream trigger error:", err);
180
+ });
181
+ } catch (err) {
182
+ console.warn("[sentient/tick] dream trigger threw:", err);
183
+ }
184
+ }
185
+ async function writeFailureReceipt(projectRoot, taskId, attempt, exitCode, reason) {
186
+ try {
187
+ const { Cleo } = await import("@cleocode/core/sdk");
188
+ const cleo = await Cleo.init(projectRoot);
189
+ await cleo.memory.observe({
190
+ text: `sentient-tier1: task ${taskId} failed (attempt=${attempt}/${MAX_TASK_ATTEMPTS}, exit=${exitCode}). reason=${reason.slice(0, 500)}`,
191
+ title: `sentient-failure: ${taskId}`
192
+ });
193
+ } catch {
194
+ }
195
+ }
196
+ async function runTick(options) {
197
+ const { projectRoot, statePath } = options;
198
+ const adapter = options.adapter ?? DEFAULT_ADAPTER;
199
+ const now = Date.now();
200
+ if (await killSwitchActive(statePath)) {
201
+ await incrementStats(statePath, { ticksKilled: 1 });
202
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
203
+ return { kind: "killed", taskId: null, detail: "killSwitch active before pick" };
204
+ }
205
+ const picker = options.pickTask ?? defaultPickTask;
206
+ let task;
207
+ try {
208
+ task = await picker(projectRoot);
209
+ } catch (err) {
210
+ const message = err instanceof Error ? err.message : String(err);
211
+ await incrementStats(statePath, { ticksExecuted: 1 });
212
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
213
+ return { kind: "error", taskId: null, detail: `picker threw: ${message}` };
214
+ }
215
+ if (task === null) {
216
+ await incrementStats(statePath, { ticksExecuted: 1 });
217
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
218
+ return { kind: "no-task", taskId: null, detail: "no unblocked tasks available" };
219
+ }
220
+ const preSpawnState = await readSentientState(statePath);
221
+ const existingStuck = preSpawnState.stuckTasks[task.id];
222
+ if (existingStuck && existingStuck.nextRetryAt > now) {
223
+ await incrementStats(statePath, { ticksExecuted: 1 });
224
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
225
+ return {
226
+ kind: "backoff",
227
+ taskId: task.id,
228
+ detail: `task ${task.id} in backoff until ${new Date(existingStuck.nextRetryAt).toISOString()}`
229
+ };
230
+ }
231
+ if (await killSwitchActive(statePath)) {
232
+ await incrementStats(statePath, { ticksKilled: 1 });
233
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
234
+ return { kind: "killed", taskId: task.id, detail: "killSwitch active before spawn" };
235
+ }
236
+ await incrementStats(statePath, { tasksPicked: 1 });
237
+ await patchSentientState(statePath, { activeTaskId: task.id });
238
+ let spawnResult;
239
+ if (options.dryRun === true) {
240
+ spawnResult = {
241
+ exitCode: 0,
242
+ stdout: "[dry-run] spawn skipped",
243
+ stderr: ""
244
+ };
245
+ } else {
246
+ try {
247
+ const spawner = options.spawn ?? ((tid, adp) => defaultSpawn(tid, adp, projectRoot));
248
+ spawnResult = await spawner(task.id, adapter);
249
+ } catch (err) {
250
+ const message = err instanceof Error ? err.message : String(err);
251
+ spawnResult = { exitCode: 1, stdout: "", stderr: `spawn threw: ${message}` };
252
+ }
253
+ }
254
+ if (await killSwitchActive(statePath)) {
255
+ await incrementStats(statePath, { ticksKilled: 1 });
256
+ await patchSentientState(statePath, {
257
+ lastTickAt: new Date(Date.now()).toISOString(),
258
+ activeTaskId: null
259
+ });
260
+ return {
261
+ kind: "killed",
262
+ taskId: task.id,
263
+ detail: "killSwitch active after spawn; result not recorded"
264
+ };
265
+ }
266
+ if (spawnResult.exitCode === 0) {
267
+ await writeSuccessReceipt(projectRoot, task.id, spawnResult.exitCode);
268
+ const post2 = await readSentientState(statePath);
269
+ const { [task.id]: _removed, ...rest } = post2.stuckTasks;
270
+ void _removed;
271
+ await patchSentientState(statePath, {
272
+ stuckTasks: rest,
273
+ activeTaskId: null,
274
+ lastTickAt: new Date(Date.now()).toISOString()
275
+ });
276
+ await incrementStats(statePath, { tasksCompleted: 1, ticksExecuted: 1 });
277
+ return {
278
+ kind: "success",
279
+ taskId: task.id,
280
+ detail: `task ${task.id} completed (exit=0)`
281
+ };
282
+ }
283
+ const currentAttempts = existingStuck?.attempts ?? 0;
284
+ const nextAttempts = currentAttempts + 1;
285
+ const failureReason = spawnResult.stderr.slice(-500) || `exit=${spawnResult.exitCode}`;
286
+ await writeFailureReceipt(
287
+ projectRoot,
288
+ task.id,
289
+ nextAttempts,
290
+ spawnResult.exitCode,
291
+ failureReason
292
+ );
293
+ await incrementStats(statePath, { tasksFailed: 1, ticksExecuted: 1 });
294
+ if (nextAttempts >= MAX_TASK_ATTEMPTS) {
295
+ const windowed = pruneStuckWindow(preSpawnState.stuckTimestamps, now);
296
+ windowed.push(now);
297
+ const stuckRecord2 = {
298
+ attempts: nextAttempts,
299
+ lastFailureAt: new Date(now).toISOString(),
300
+ nextRetryAt: Number.MAX_SAFE_INTEGER,
301
+ // owner-only release
302
+ lastReason: failureReason
303
+ };
304
+ const post2 = await readSentientState(statePath);
305
+ const updatedStuckTasks = {
306
+ ...post2.stuckTasks,
307
+ [task.id]: stuckRecord2
308
+ };
309
+ const shouldSelfPause = windowed.length >= SELF_PAUSE_STUCK_THRESHOLD;
310
+ await patchSentientState(statePath, {
311
+ stuckTasks: updatedStuckTasks,
312
+ stuckTimestamps: windowed,
313
+ activeTaskId: null,
314
+ lastTickAt: new Date(now).toISOString(),
315
+ ...shouldSelfPause ? { killSwitch: true, killSwitchReason: SELF_PAUSE_REASON } : {}
316
+ });
317
+ if (shouldSelfPause) {
318
+ return {
319
+ kind: "self-paused",
320
+ taskId: task.id,
321
+ detail: `task ${task.id} is stuck; self-pause fired (${windowed.length}/${SELF_PAUSE_STUCK_THRESHOLD} stucks in window)`
322
+ };
323
+ }
324
+ return {
325
+ kind: "stuck",
326
+ taskId: task.id,
327
+ detail: `task ${task.id} stuck after ${nextAttempts} attempts; owner must re-enable via \`cleo sentient resume\``
328
+ };
329
+ }
330
+ const backoff = RETRY_BACKOFF_MS[nextAttempts - 1] ?? RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1];
331
+ const stuckRecord = {
332
+ attempts: nextAttempts,
333
+ lastFailureAt: new Date(now).toISOString(),
334
+ nextRetryAt: now + backoff,
335
+ lastReason: failureReason
336
+ };
337
+ const post = await readSentientState(statePath);
338
+ await patchSentientState(statePath, {
339
+ stuckTasks: { ...post.stuckTasks, [task.id]: stuckRecord },
340
+ activeTaskId: null,
341
+ lastTickAt: new Date(now).toISOString()
342
+ });
343
+ return {
344
+ kind: "failure",
345
+ taskId: task.id,
346
+ detail: `task ${task.id} failed (attempt=${nextAttempts}/${MAX_TASK_ATTEMPTS}); retry scheduled at ${new Date(now + backoff).toISOString()}`
347
+ };
348
+ }
349
+ async function safeRunTick(options) {
350
+ let outcome;
351
+ try {
352
+ outcome = await runTick(options);
353
+ } catch (err) {
354
+ const message = err instanceof Error ? err.message : String(err);
355
+ try {
356
+ await incrementStats(options.statePath, { ticksExecuted: 1 });
357
+ } catch {
358
+ }
359
+ outcome = { kind: "error", taskId: null, detail: `tick threw: ${message}` };
360
+ }
361
+ const pickedTask = outcome.kind !== "no-task" && outcome.kind !== "killed" && outcome.kind !== "error" && outcome.taskId !== null;
362
+ await maybeTriggerDream(options.projectRoot, options, pickedTask).catch(() => {
363
+ });
364
+ return outcome;
365
+ }
366
+ function isFailureOutcome(outcome) {
367
+ return outcome.kind === "failure" || outcome.kind === "stuck" || outcome.kind === "self-paused";
368
+ }
369
+ async function getKillStatus(statePath) {
370
+ const state = await readSentientState(statePath);
371
+ return { killSwitch: state.killSwitch, killSwitchReason: state.killSwitchReason };
372
+ }
373
+ function _resetDreamTickState() {
374
+ consecutiveIdleTicks = 0;
375
+ }
376
+ function _getConsecutiveIdleTicks() {
377
+ return consecutiveIdleTicks;
378
+ }
379
+ export {
380
+ DEFAULT_ADAPTER,
381
+ DREAM_IDLE_TICKS_DEFAULT,
382
+ DREAM_VOLUME_THRESHOLD_DEFAULT,
383
+ MAX_TASK_ATTEMPTS,
384
+ RETRY_BACKOFF_MS,
385
+ SELF_PAUSE_REASON,
386
+ SELF_PAUSE_STUCK_THRESHOLD,
387
+ SELF_PAUSE_WINDOW_MS,
388
+ SPAWN_TIMEOUT_MS,
389
+ _getConsecutiveIdleTicks,
390
+ _resetDreamTickState,
391
+ getKillStatus,
392
+ isFailureOutcome,
393
+ runTick,
394
+ safeRunTick
395
+ };
396
+ //# sourceMappingURL=tick.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/sentient/tick.ts", "../../src/sentient/state.ts"],
4
+ "sourcesContent": ["/**\n * Sentient Loop Tick \u2014 Single-iteration tick runner for the Tier-1 daemon.\n *\n * A tick is one complete pass of:\n * 1. Check killSwitch (abort if true)\n * 2. Pick an unblocked task via @cleocode/core/sdk\n * 3. Check killSwitch again (abort if true)\n * 4. Spawn worker via `cleo orchestrate spawn <taskId> --adapter <adapter>`\n * 5. Check killSwitch again before recording result\n * 6. Record success (receipt + stats) or failure (retry/backoff)\n *\n * Each step re-reads the state file so that a killSwitch flipped mid-tick is\n * honoured on the very next instruction (Round 2 audit \u00A71: \"mid-experiment\n * kill limbo\").\n *\n * Rate limit: driven by the cron schedule (`*\\/5 * * * *` \u2192 \u226412 ticks/hour \u2264\n * 12 spawns/hour). No in-tick sleep is required \u2014 cron provides the cadence.\n *\n * Scoped OUT: Tier 2 (propose) and Tier 3 (sandbox auto-merge) per ADR-054.\n *\n * @task T946\n * @see ADR-054 \u2014 Sentient Loop Tier-1\n */\n\nimport { spawn } from 'node:child_process';\nimport type { Task } from '@cleocode/contracts';\nimport {\n incrementStats,\n patchSentientState,\n readSentientState,\n type SentientState,\n type StuckTaskRecord,\n} from './state.js';\n\n// NOTE: `checkAndDream` is lazy-imported inside `maybeTriggerDream` to keep the\n// test surface small \u2014 tests that don't exercise the dream path never load\n// the brain.db stack.\n\n// ---------------------------------------------------------------------------\n// Dream-cycle trigger constants (T996)\n// ---------------------------------------------------------------------------\n\n/**\n * Number of new brain observations since the last consolidation that causes\n * the tick loop to trigger a dream cycle (volume tier).\n * Configurable via the injected `dreamVolumeThreshold` option.\n */\nexport const DREAM_VOLUME_THRESHOLD_DEFAULT = 50;\n\n/**\n * Number of consecutive no-task ticks before the idle dream trigger fires.\n * Represents \"N idle ticks\" \u2014 when no task has been picked for this many\n * consecutive ticks, the system is considered sufficiently idle.\n */\nexport const DREAM_IDLE_TICKS_DEFAULT = 5;\n\n// NOTE: `@cleocode/core/sdk` and `@cleocode/core/tasks` are lazy-imported\n// inside the helpers that use them (`defaultPickTask`, writeSuccessReceipt,\n// writeFailureReceipt). That keeps the test surface tiny \u2014 tests that inject\n// their own `pickTask` / `spawn` never trigger the real SDK load.\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Default adapter used when spawning workers. */\nexport const DEFAULT_ADAPTER = 'claude-code' as const;\n\n/**\n * Backoff delays between successive retries for the same task (milliseconds).\n * Index n = delay before attempt n+1. After exhaustion the task is `stuck`.\n * 30 s \u2192 5 min \u2192 30 min, then the task is marked stuck.\n */\nexport const RETRY_BACKOFF_MS: readonly number[] = [30_000, 300_000, 1_800_000];\n\n/**\n * Maximum spawn attempts per task before it is classified as `stuck`.\n * Matches RETRY_BACKOFF_MS.length but surfaced as a named constant for\n * readability in tests and status output.\n */\nexport const MAX_TASK_ATTEMPTS = RETRY_BACKOFF_MS.length;\n\n/**\n * Threshold for self-pause: if this many tasks become `stuck` within a\n * rolling 1-hour window, the daemon flips killSwitch=true and exits.\n */\nexport const SELF_PAUSE_STUCK_THRESHOLD = 5;\n\n/** Rolling window (ms) used for stuck-rate calculation. */\nexport const SELF_PAUSE_WINDOW_MS = 60 * 60 * 1000;\n\n/** Reason stored on the state file when self-pause fires. */\nexport const SELF_PAUSE_REASON = 'self-pause: 5 stuck tasks in 1 hour';\n\n/** Max wall-clock time for a single spawn before forceful kill (30 min). */\nexport const SPAWN_TIMEOUT_MS = 30 * 60 * 1000;\n\n// ---------------------------------------------------------------------------\n// Tick outcome types\n// ---------------------------------------------------------------------------\n\n/** Discriminant for the tick outcome. */\nexport type TickOutcomeKind =\n | 'killed' // killSwitch was active at some checkpoint\n | 'no-task' // no unblocked task available\n | 'backoff' // a task is in retry backoff, skipped this tick\n | 'success' // spawn exited 0\n | 'failure' // spawn exited non-zero, retry scheduled\n | 'stuck' // attempts exhausted, task marked stuck\n | 'self-paused' // stuck-rate threshold tripped self-pause\n | 'error'; // unexpected error in tick machinery itself\n\n/** Structured outcome of a single tick. */\nexport interface TickOutcome {\n /** Discriminant describing how the tick ended. */\n kind: TickOutcomeKind;\n /** Task id that was the subject of this tick (if any). */\n taskId: string | null;\n /** Human-readable detail (one line). */\n detail: string;\n}\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/** Options for {@link runTick}. */\nexport interface TickOptions {\n /** Absolute path to the project root (contains `.cleo/`). */\n projectRoot: string;\n /** Absolute path to sentient-state.json. */\n statePath: string;\n /**\n * Adapter to pass to `cleo orchestrate spawn --adapter`. Defaults to\n * {@link DEFAULT_ADAPTER}. Overridden in tests via options.spawn.\n */\n adapter?: string;\n /**\n * Dry-run mode: skip the actual `cleo orchestrate spawn` subprocess and\n * treat the pick as a no-op (still records picked stat). Used by\n * `cleo sentient tick --dry-run`.\n */\n dryRun?: boolean;\n /**\n * Override for the spawn function \u2014 lets tests inject a deterministic fake\n * without forking real subprocesses. Must resolve to an exit code.\n *\n * When omitted, the default implementation spawns\n * `cleo orchestrate spawn <taskId> --adapter <adapter>` and resolves with\n * the child's exit code.\n */\n spawn?: (taskId: string, adapter: string) => Promise<SpawnResult>;\n /**\n * Override for the \"pick next unblocked task\" source. Lets tests return\n * a deterministic task without constructing a SQLite fixture.\n */\n pickTask?: (projectRoot: string) => Promise<Task | null>;\n /**\n * New observation count since last consolidation that triggers the volume\n * dream cycle. Defaults to {@link DREAM_VOLUME_THRESHOLD_DEFAULT}.\n * Pass 0 to disable volume trigger. Injected by tests.\n */\n dreamVolumeThreshold?: number;\n /**\n * Number of consecutive no-task ticks before the idle dream trigger fires.\n * Defaults to {@link DREAM_IDLE_TICKS_DEFAULT}.\n * Pass 0 to disable idle trigger. Injected by tests.\n */\n dreamIdleTicks?: number;\n /**\n * Override for the dream trigger function \u2014 lets tests assert dream calls\n * without touching the real brain.db stack.\n * Signature mirrors `checkAndDream` from `@cleocode/core`.\n */\n checkAndDream?: (\n projectRoot: string,\n opts?: { volumeThreshold?: number; inline?: boolean },\n ) => Promise<{ triggered: boolean; tier: string | null; skippedReason?: string }>;\n}\n\n/** Result of a spawn invocation. */\nexport interface SpawnResult {\n /** Process exit code (0 = success). */\n exitCode: number;\n /** Captured stdout, truncated by the caller if needed. */\n stdout: string;\n /** Captured stderr, truncated by the caller if needed. */\n stderr: string;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Fresh-load state and return true if killSwitch is active. Used at every\n * checkpoint to avoid mid-tick kill limbo (Round 2 audit \u00A71).\n */\nasync function killSwitchActive(statePath: string): Promise<boolean> {\n const state = await readSentientState(statePath);\n return state.killSwitch === true;\n}\n\n/**\n * Build the list of stuck timestamps that fall inside the rolling window.\n */\nfunction pruneStuckWindow(timestamps: readonly number[], now: number): number[] {\n const cutoff = now - SELF_PAUSE_WINDOW_MS;\n return timestamps.filter((t) => t >= cutoff);\n}\n\n/**\n * Default SDK-backed task picker. Delegates to the orchestration domain via\n * the @cleocode/core/sdk facade.\n *\n * Tier-1 scope: we pick any unblocked, non-proposed task regardless of which\n * epic it belongs to \u2014 the picker walks the full task set to find the next\n * actionable item.\n */\nasync function defaultPickTask(projectRoot: string): Promise<Task | null> {\n // Lazy import so unit tests that inject `pickTask` never trigger the SDK\n // load (which pulls in the full @cleocode/core graph).\n const { Cleo } = await import('@cleocode/core/sdk');\n const { getReadyTasks } = await import('@cleocode/core/tasks');\n\n const cleo = await Cleo.init(projectRoot);\n // Use find() to get candidate tasks. We specifically avoid 'proposed' by\n // only filtering on pending/active/blocked. getReadyTasks() from the\n // dependency-check module is authoritative for \"unblocked\".\n const pending = (await cleo.tasks.find({ status: 'pending', limit: 500 })) as {\n success?: boolean;\n data?: { tasks?: Task[] };\n };\n const candidates: Task[] = Array.isArray(pending?.data?.tasks) ? pending.data.tasks : [];\n if (candidates.length === 0) return null;\n\n const ready = getReadyTasks(candidates);\n if (ready.length === 0) return null;\n\n // Deterministic pick: lowest id wins (reproducible for tests).\n ready.sort((a, b) => a.id.localeCompare(b.id));\n return ready[0];\n}\n\n/**\n * Default spawn implementation. Shells out to\n * `cleo orchestrate spawn <taskId> --adapter <adapter>` and captures output.\n *\n * Note: we MUST shell out here \u2014 the spawn verb shells out to\n * claude-code / gemini-cli / ollama as external tools. Using the SDK\n * directly is not possible without re-implementing adapter dispatch.\n */\nfunction defaultSpawn(taskId: string, adapter: string, projectRoot: string): Promise<SpawnResult> {\n return new Promise<SpawnResult>((resolve) => {\n const args = ['orchestrate', 'spawn', taskId, '--adapter', adapter];\n const child = spawn('cleo', args, {\n cwd: projectRoot,\n env: { ...process.env, CLEO_SENTIENT_SPAWN: '1' },\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n\n let stdout = '';\n let stderr = '';\n\n child.stdout?.on('data', (chunk: Buffer) => {\n stdout += chunk.toString('utf-8');\n });\n child.stderr?.on('data', (chunk: Buffer) => {\n stderr += chunk.toString('utf-8');\n });\n\n const timer = setTimeout(() => {\n child.kill('SIGTERM');\n }, SPAWN_TIMEOUT_MS);\n\n child.on('error', (err: Error) => {\n clearTimeout(timer);\n resolve({\n exitCode: 1,\n stdout,\n stderr: stderr + `\\n[sentient] spawn error: ${err.message}`,\n });\n });\n child.on('exit', (code) => {\n clearTimeout(timer);\n resolve({\n exitCode: code ?? 1,\n stdout: stdout.slice(-4000),\n stderr: stderr.slice(-4000),\n });\n });\n });\n}\n\n/**\n * Record a successful spawn to the brain via `memory.observe`.\n * Swallows errors: receipt write must never break the tick.\n */\nasync function writeSuccessReceipt(\n projectRoot: string,\n taskId: string,\n exitCode: number,\n): Promise<void> {\n try {\n const { Cleo } = await import('@cleocode/core/sdk');\n const cleo = await Cleo.init(projectRoot);\n await cleo.memory.observe({\n text: `sentient-tier1: task ${taskId} completed successfully (exit=${exitCode})`,\n title: `sentient-receipt: ${taskId}`,\n });\n } catch {\n // Receipt is best-effort; do not fail the tick.\n }\n}\n\n// ---------------------------------------------------------------------------\n// Dream-cycle trigger state (T996)\n// ---------------------------------------------------------------------------\n\n/**\n * Number of consecutive no-task ticks since the last successful task pick.\n * Used by the idle dream trigger: when this counter reaches `dreamIdleTicks`,\n * `checkAndDream` is called with the idle tier.\n *\n * Reset to 0 whenever a task is successfully picked.\n */\nlet consecutiveIdleTicks = 0;\n\n/**\n * Evaluate volume + idle dream triggers and call `checkAndDream` when either\n * fires. Errors are swallowed \u2014 dream trigger must never crash the tick.\n *\n * @param projectRoot - Project root for brain.db resolution.\n * @param opts - Tick options (provides thresholds + injectable checkAndDream).\n * @param pickedTask - Whether a task was picked this tick (resets idle counter).\n */\nasync function maybeTriggerDream(\n projectRoot: string,\n opts: TickOptions,\n pickedTask: boolean,\n): Promise<void> {\n const volumeThreshold = opts.dreamVolumeThreshold ?? DREAM_VOLUME_THRESHOLD_DEFAULT;\n const idleTicksThreshold = opts.dreamIdleTicks ?? DREAM_IDLE_TICKS_DEFAULT;\n\n // Disable both triggers when thresholds are 0 (test escape hatch).\n if (volumeThreshold <= 0 && idleTicksThreshold <= 0) return;\n\n if (pickedTask) {\n consecutiveIdleTicks = 0;\n } else {\n consecutiveIdleTicks += 1;\n }\n\n const dreamer =\n opts.checkAndDream ??\n (async (root: string, dreamerOpts?: { volumeThreshold?: number; inline?: boolean }) => {\n const { checkAndDream } = await import('@cleocode/core/internal');\n return checkAndDream(root, dreamerOpts);\n });\n\n try {\n await dreamer(projectRoot, {\n volumeThreshold: volumeThreshold > 0 ? volumeThreshold : undefined,\n inline: false,\n }).catch((err: unknown) => {\n console.warn('[sentient/tick] dream trigger error:', err);\n });\n } catch (err) {\n console.warn('[sentient/tick] dream trigger threw:', err);\n }\n}\n\n/**\n * Record a failure to the brain via `memory.observe`.\n * Swallows errors: receipt write must never break the tick.\n */\nasync function writeFailureReceipt(\n projectRoot: string,\n taskId: string,\n attempt: number,\n exitCode: number,\n reason: string,\n): Promise<void> {\n try {\n const { Cleo } = await import('@cleocode/core/sdk');\n const cleo = await Cleo.init(projectRoot);\n await cleo.memory.observe({\n text:\n `sentient-tier1: task ${taskId} failed (attempt=${attempt}/${MAX_TASK_ATTEMPTS}, ` +\n `exit=${exitCode}). reason=${reason.slice(0, 500)}`,\n title: `sentient-failure: ${taskId}`,\n });\n } catch {\n // Receipt is best-effort.\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Run a single tick of the sentient loop.\n *\n * Every checkpoint re-reads state so that a killSwitch flipped mid-tick is\n * honoured on the next instruction.\n *\n * @param options - Tick options (see {@link TickOptions})\n * @returns Structured outcome describing how the tick ended.\n */\nexport async function runTick(options: TickOptions): Promise<TickOutcome> {\n const { projectRoot, statePath } = options;\n const adapter = options.adapter ?? DEFAULT_ADAPTER;\n const now = Date.now();\n\n // -- Checkpoint 1: killSwitch before any work ------------------------------\n if (await killSwitchActive(statePath)) {\n await incrementStats(statePath, { ticksKilled: 1 });\n await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });\n return { kind: 'killed', taskId: null, detail: 'killSwitch active before pick' };\n }\n\n // -- Pick next unblocked task ---------------------------------------------\n const picker = options.pickTask ?? defaultPickTask;\n let task: Task | null;\n try {\n task = await picker(projectRoot);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n await incrementStats(statePath, { ticksExecuted: 1 });\n await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });\n return { kind: 'error', taskId: null, detail: `picker threw: ${message}` };\n }\n\n if (task === null) {\n await incrementStats(statePath, { ticksExecuted: 1 });\n await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });\n return { kind: 'no-task', taskId: null, detail: 'no unblocked tasks available' };\n }\n\n // -- Respect per-task backoff ---------------------------------------------\n const preSpawnState = await readSentientState(statePath);\n const existingStuck: StuckTaskRecord | undefined = preSpawnState.stuckTasks[task.id];\n if (existingStuck && existingStuck.nextRetryAt > now) {\n await incrementStats(statePath, { ticksExecuted: 1 });\n await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });\n return {\n kind: 'backoff',\n taskId: task.id,\n detail: `task ${task.id} in backoff until ${new Date(existingStuck.nextRetryAt).toISOString()}`,\n };\n }\n\n // -- Checkpoint 2: killSwitch before spawn --------------------------------\n if (await killSwitchActive(statePath)) {\n await incrementStats(statePath, { ticksKilled: 1 });\n await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });\n return { kind: 'killed', taskId: task.id, detail: 'killSwitch active before spawn' };\n }\n\n // -- Mark task active ------------------------------------------------------\n await incrementStats(statePath, { tasksPicked: 1 });\n await patchSentientState(statePath, { activeTaskId: task.id });\n\n // -- Spawn worker ---------------------------------------------------------\n let spawnResult: SpawnResult;\n if (options.dryRun === true) {\n spawnResult = {\n exitCode: 0,\n stdout: '[dry-run] spawn skipped',\n stderr: '',\n };\n } else {\n try {\n const spawner = options.spawn ?? ((tid, adp) => defaultSpawn(tid, adp, projectRoot));\n spawnResult = await spawner(task.id, adapter);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n spawnResult = { exitCode: 1, stdout: '', stderr: `spawn threw: ${message}` };\n }\n }\n\n // -- Checkpoint 3: killSwitch before recording ----------------------------\n if (await killSwitchActive(statePath)) {\n await incrementStats(statePath, { ticksKilled: 1 });\n await patchSentientState(statePath, {\n lastTickAt: new Date(Date.now()).toISOString(),\n activeTaskId: null,\n });\n return {\n kind: 'killed',\n taskId: task.id,\n detail: 'killSwitch active after spawn; result not recorded',\n };\n }\n\n // -- Classify + record -----------------------------------------------------\n if (spawnResult.exitCode === 0) {\n await writeSuccessReceipt(projectRoot, task.id, spawnResult.exitCode);\n // Clear stuck entry on success.\n const post = await readSentientState(statePath);\n const { [task.id]: _removed, ...rest } = post.stuckTasks;\n void _removed;\n await patchSentientState(statePath, {\n stuckTasks: rest,\n activeTaskId: null,\n lastTickAt: new Date(Date.now()).toISOString(),\n });\n await incrementStats(statePath, { tasksCompleted: 1, ticksExecuted: 1 });\n return {\n kind: 'success',\n taskId: task.id,\n detail: `task ${task.id} completed (exit=0)`,\n };\n }\n\n // -- Failure path: increment attempts, record backoff or stuck -----------\n const currentAttempts = existingStuck?.attempts ?? 0;\n const nextAttempts = currentAttempts + 1;\n const failureReason = spawnResult.stderr.slice(-500) || `exit=${spawnResult.exitCode}`;\n\n await writeFailureReceipt(\n projectRoot,\n task.id,\n nextAttempts,\n spawnResult.exitCode,\n failureReason,\n );\n await incrementStats(statePath, { tasksFailed: 1, ticksExecuted: 1 });\n\n if (nextAttempts >= MAX_TASK_ATTEMPTS) {\n // Mark task stuck. Record timestamp in rolling window; self-pause if \u2265 threshold.\n const windowed = pruneStuckWindow(preSpawnState.stuckTimestamps, now);\n windowed.push(now);\n\n const stuckRecord: StuckTaskRecord = {\n attempts: nextAttempts,\n lastFailureAt: new Date(now).toISOString(),\n nextRetryAt: Number.MAX_SAFE_INTEGER, // owner-only release\n lastReason: failureReason,\n };\n\n const post = await readSentientState(statePath);\n const updatedStuckTasks: Record<string, StuckTaskRecord> = {\n ...post.stuckTasks,\n [task.id]: stuckRecord,\n };\n\n const shouldSelfPause = windowed.length >= SELF_PAUSE_STUCK_THRESHOLD;\n\n await patchSentientState(statePath, {\n stuckTasks: updatedStuckTasks,\n stuckTimestamps: windowed,\n activeTaskId: null,\n lastTickAt: new Date(now).toISOString(),\n ...(shouldSelfPause ? { killSwitch: true, killSwitchReason: SELF_PAUSE_REASON } : {}),\n });\n\n if (shouldSelfPause) {\n return {\n kind: 'self-paused',\n taskId: task.id,\n detail:\n `task ${task.id} is stuck; self-pause fired ` +\n `(${windowed.length}/${SELF_PAUSE_STUCK_THRESHOLD} stucks in window)`,\n };\n }\n\n return {\n kind: 'stuck',\n taskId: task.id,\n detail:\n `task ${task.id} stuck after ${nextAttempts} attempts; ` +\n `owner must re-enable via \\`cleo sentient resume\\``,\n };\n }\n\n // Schedule next retry with backoff.\n const backoff =\n RETRY_BACKOFF_MS[nextAttempts - 1] ?? RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1];\n const stuckRecord: StuckTaskRecord = {\n attempts: nextAttempts,\n lastFailureAt: new Date(now).toISOString(),\n nextRetryAt: now + backoff,\n lastReason: failureReason,\n };\n const post = await readSentientState(statePath);\n await patchSentientState(statePath, {\n stuckTasks: { ...post.stuckTasks, [task.id]: stuckRecord },\n activeTaskId: null,\n lastTickAt: new Date(now).toISOString(),\n });\n\n return {\n kind: 'failure',\n taskId: task.id,\n detail:\n `task ${task.id} failed (attempt=${nextAttempts}/${MAX_TASK_ATTEMPTS}); ` +\n `retry scheduled at ${new Date(now + backoff).toISOString()}`,\n };\n}\n\n/**\n * Convenience wrapper used by the daemon cron handler and the `cleo sentient\n * tick` CLI command. Reads state, runs a tick, swallows any unexpected\n * exception so the cron scheduler never sees a rejection.\n *\n * After the tick completes, evaluates volume + idle dream triggers via\n * {@link maybeTriggerDream}. Dream errors are swallowed independently so\n * they never affect the tick outcome.\n *\n * @param options - Tick options\n * @returns The tick outcome (or an `error` outcome if the tick itself threw).\n */\nexport async function safeRunTick(options: TickOptions): Promise<TickOutcome> {\n let outcome: TickOutcome;\n try {\n outcome = await runTick(options);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n try {\n await incrementStats(options.statePath, { ticksExecuted: 1 });\n } catch {\n // ignore\n }\n outcome = { kind: 'error', taskId: null, detail: `tick threw: ${message}` };\n }\n\n // Dream trigger: fire volume + idle checks after every tick.\n // A task was \"picked\" when the tick progressed past the no-task check\n // (i.e. kind is not 'no-task', 'killed', or 'error').\n const pickedTask =\n outcome.kind !== 'no-task' &&\n outcome.kind !== 'killed' &&\n outcome.kind !== 'error' &&\n outcome.taskId !== null;\n\n await maybeTriggerDream(options.projectRoot, options, pickedTask).catch(() => {\n // Dream errors must never propagate to the tick caller.\n });\n\n return outcome;\n}\n\n/**\n * Type-narrowing helper used by tests and status rendering to identify tick\n * outcomes that consumed a retry attempt.\n */\nexport function isFailureOutcome(\n outcome: TickOutcome,\n): outcome is TickOutcome & { kind: 'failure' | 'stuck' | 'self-paused' } {\n return outcome.kind === 'failure' || outcome.kind === 'stuck' || outcome.kind === 'self-paused';\n}\n\n/**\n * Returns a shallow view of the current state's kill status.\n * Exposed for diagnostic/test consumers.\n */\nexport async function getKillStatus(\n statePath: string,\n): Promise<Pick<SentientState, 'killSwitch' | 'killSwitchReason'>> {\n const state = await readSentientState(statePath);\n return { killSwitch: state.killSwitch, killSwitchReason: state.killSwitchReason };\n}\n\n/**\n * Reset dream-cycle in-process state.\n *\n * Intended for test teardown only \u2014 clears `consecutiveIdleTicks` so that\n * successive test cases start from a clean slate.\n *\n * @internal\n */\nexport function _resetDreamTickState(): void {\n consecutiveIdleTicks = 0;\n}\n\n/**\n * Return the current consecutive-idle-tick counter value.\n *\n * Read-only accessor for test assertions. The counter is reset to 0 whenever\n * a task is picked, and incremented on each no-task tick.\n *\n * @internal\n */\nexport function _getConsecutiveIdleTicks(): number {\n return consecutiveIdleTicks;\n}\n", "/**\n * Sentient Loop State \u2014 Persistent state for the Tier-1 autonomous daemon.\n *\n * Stored in `.cleo/sentient-state.json` (plain JSON, not SQLite) to avoid\n * SQLite WAL conflicts between the long-running daemon process and the\n * main CLEO CLI process. Human-readable for debugging.\n *\n * The file is gitignored (see .gitignore \u00A7.cleo/ section) and survives\n * restarts. Only `killSwitch`, `pid`, and `stats` fields are load-bearing\n * across process boundaries.\n *\n * @see ADR-054 \u2014 Sentient Loop Tier-1 (autonomous task execution)\n * @task T946\n */\n\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport type { Tier2Stats } from '@cleocode/contracts';\n\n/** Schema version for sentient-state.json. Bump on breaking field changes. */\nexport const SENTIENT_STATE_SCHEMA_VERSION = '1.0' as const;\n\n/**\n * Per-task failure/backoff tracking for stuck detection.\n * Keyed by task id in {@link SentientState.stuckTasks}.\n */\nexport interface StuckTaskRecord {\n /** Number of consecutive failed spawn attempts for this task. */\n attempts: number;\n /** ISO-8601 timestamp of the most recent failure. */\n lastFailureAt: string;\n /** Unix epoch ms when the next retry becomes eligible. */\n nextRetryAt: number;\n /** Last captured failure reason (truncated to 500 chars). */\n lastReason: string;\n}\n\n/**\n * Rolling counters persisted across daemon restarts.\n */\nexport interface SentientStats {\n /** Total tasks picked by the loop since creation. */\n tasksPicked: number;\n /** Total tasks that completed successfully. */\n tasksCompleted: number;\n /** Total tasks whose spawn exited non-zero. */\n tasksFailed: number;\n /** Total ticks executed (including no-op ticks). */\n ticksExecuted: number;\n /** Total ticks aborted early because kill switch was active. */\n ticksKilled: number;\n}\n\n/**\n * Persistent sentient daemon state.\n *\n * Design principles:\n * - `killSwitch` is the single load-bearing kill signal \u2014 the daemon re-checks\n * it between every step of a tick, not just at tick start (Round 2 audit).\n * - `stuckTasks` keys are task ids; values encode backoff + failure counts.\n * - `stuckTimestamps` is a rolling 1-hour window used for the self-pause rule\n * (5 stucks in 1 hour \u2192 killSwitch=true).\n * - `stats` fields are monotonic counters that only ever increase.\n */\nexport interface SentientState {\n /** JSON schema version for forward-compatibility checks. */\n schemaVersion: typeof SENTIENT_STATE_SCHEMA_VERSION;\n /** PID of the currently running daemon process. null = daemon not running. */\n pid: number | null;\n /** ISO-8601 timestamp when the daemon was last started. */\n startedAt: string | null;\n /** ISO-8601 timestamp of the last completed tick (any outcome). */\n lastTickAt: string | null;\n /**\n * Kill-switch flag. When true, the daemon re-checks at every step of a tick\n * and exits cleanly without picking or spawning a task.\n */\n killSwitch: boolean;\n /** Reason supplied when killSwitch was last set (diagnostic only). */\n killSwitchReason: string | null;\n /** Rolling counters; see {@link SentientStats}. */\n stats: SentientStats;\n /** Per-task backoff + failure metadata for retry/stuck detection. */\n stuckTasks: Record<string, StuckTaskRecord>;\n /**\n * Unix-epoch-ms timestamps of `stuck` events within the last hour.\n * When length \u2265 5 the daemon self-pauses (killSwitch=true).\n */\n stuckTimestamps: number[];\n /**\n * Currently-active task id (set while a spawn is in-flight, cleared afterward).\n * Enables `status` to show the in-progress task during a long-running tick.\n */\n activeTaskId: string | null;\n /**\n * Tier-2 proposal queue enabled flag.\n *\n * Default: `false` \u2014 Tier 2 is OFF by default to prevent surprise proposal\n * floods on first daemon start. Owner enables via `cleo sentient propose enable`\n * (patches this flag). See ADR-054 \u00A7Tier-2.\n *\n * @task T1008\n */\n tier2Enabled: boolean;\n /**\n * Rolling counters for Tier-2 proposal activity.\n *\n * @task T1008\n */\n tier2Stats: Tier2Stats;\n}\n\n/** Default (empty) sentient state for fresh initialisation. */\nexport const DEFAULT_SENTIENT_STATE: SentientState = {\n schemaVersion: SENTIENT_STATE_SCHEMA_VERSION,\n pid: null,\n startedAt: null,\n lastTickAt: null,\n killSwitch: false,\n killSwitchReason: null,\n stats: {\n tasksPicked: 0,\n tasksCompleted: 0,\n tasksFailed: 0,\n ticksExecuted: 0,\n ticksKilled: 0,\n },\n stuckTasks: {},\n stuckTimestamps: [],\n activeTaskId: null,\n tier2Enabled: false,\n tier2Stats: {\n proposalsGenerated: 0,\n proposalsAccepted: 0,\n proposalsRejected: 0,\n },\n};\n\n/**\n * Read the sentient state from disk.\n *\n * Returns the default state if the file does not exist or is malformed.\n * Never throws \u2014 absence is not an error.\n *\n * @param statePath - Absolute path to sentient-state.json\n */\nexport async function readSentientState(statePath: string): Promise<SentientState> {\n try {\n const raw = await readFile(statePath, 'utf-8');\n const parsed = JSON.parse(raw) as Partial<SentientState>;\n return {\n ...DEFAULT_SENTIENT_STATE,\n ...parsed,\n stats: { ...DEFAULT_SENTIENT_STATE.stats, ...(parsed.stats ?? {}) },\n stuckTasks: parsed.stuckTasks ?? {},\n stuckTimestamps: parsed.stuckTimestamps ?? [],\n tier2Enabled: parsed.tier2Enabled ?? false,\n tier2Stats: { ...DEFAULT_SENTIENT_STATE.tier2Stats, ...(parsed.tier2Stats ?? {}) },\n };\n } catch {\n return { ...DEFAULT_SENTIENT_STATE };\n }\n}\n\n/**\n * Write the sentient state to disk atomically via tmp-then-rename.\n *\n * Atomic write prevents partial reads if the daemon crashes mid-write.\n *\n * @param statePath - Absolute path to sentient-state.json\n * @param state - State to persist\n */\nexport async function writeSentientState(statePath: string, state: SentientState): Promise<void> {\n const dir = dirname(statePath);\n await mkdir(dir, { recursive: true });\n\n const tmpPath = join(dir, `.sentient-state-${process.pid}.tmp`);\n const json = JSON.stringify(state, null, 2);\n\n await writeFile(tmpPath, json, 'utf-8');\n await rename(tmpPath, statePath);\n}\n\n/**\n * Patch a subset of fields in the sentient state file.\n *\n * Reads current state, merges patch, writes back. Nested `stats` merges\n * with existing stats (never clobbered wholesale).\n *\n * @param statePath - Absolute path to sentient-state.json\n * @param patch - Partial state to merge over the existing state\n * @returns The merged state that was written to disk.\n */\nexport async function patchSentientState(\n statePath: string,\n patch: Partial<SentientState>,\n): Promise<SentientState> {\n const current = await readSentientState(statePath);\n const updated: SentientState = {\n ...current,\n ...patch,\n stats: { ...current.stats, ...(patch.stats ?? {}) },\n };\n await writeSentientState(statePath, updated);\n return updated;\n}\n\n/**\n * Increment stats counters atomically.\n *\n * @param statePath - Absolute path to sentient-state.json\n * @param delta - Partial stats to add to current counters\n */\nexport async function incrementStats(\n statePath: string,\n delta: Partial<SentientStats>,\n): Promise<SentientState> {\n const current = await readSentientState(statePath);\n const nextStats: SentientStats = {\n tasksPicked: current.stats.tasksPicked + (delta.tasksPicked ?? 0),\n tasksCompleted: current.stats.tasksCompleted + (delta.tasksCompleted ?? 0),\n tasksFailed: current.stats.tasksFailed + (delta.tasksFailed ?? 0),\n ticksExecuted: current.stats.ticksExecuted + (delta.ticksExecuted ?? 0),\n ticksKilled: current.stats.ticksKilled + (delta.ticksKilled ?? 0),\n };\n const updated: SentientState = { ...current, stats: nextStats };\n await writeSentientState(statePath, updated);\n return updated;\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["post", "stuckRecord"]
7
+ }
@@ -0,0 +1,36 @@
1
+ // packages/core/src/system/platform-paths.ts
2
+ import { arch, hostname, platform, release } from "node:os";
3
+ import envPaths from "env-paths";
4
+ var APP_NAME = "cleo";
5
+ var _sysInfo = null;
6
+ function getPlatformPaths() {
7
+ const ep = envPaths(APP_NAME, { suffix: "" });
8
+ return {
9
+ data: process.env["CLEO_HOME"] ?? ep.data,
10
+ config: ep.config,
11
+ cache: ep.cache,
12
+ log: ep.log,
13
+ temp: ep.temp
14
+ };
15
+ }
16
+ function getSystemInfo() {
17
+ if (_sysInfo) return _sysInfo;
18
+ _sysInfo = {
19
+ platform: platform(),
20
+ arch: arch(),
21
+ release: release(),
22
+ hostname: hostname(),
23
+ nodeVersion: process.version,
24
+ paths: getPlatformPaths()
25
+ };
26
+ return _sysInfo;
27
+ }
28
+ function _resetPlatformPathsCache() {
29
+ _sysInfo = null;
30
+ }
31
+ export {
32
+ _resetPlatformPathsCache,
33
+ getPlatformPaths,
34
+ getSystemInfo
35
+ };
36
+ //# sourceMappingURL=platform-paths.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/system/platform-paths.ts"],
4
+ "sourcesContent": ["/**\n * Central OS platform path resolution using env-paths.\n *\n * Provides OS-appropriate paths for CLEO's global directories using\n * XDG conventions on Linux, standard conventions on macOS/Windows.\n * Results are cached for the process lifetime. Env vars take precedence.\n *\n * Platform path defaults (no env vars set):\n * data: ~/.local/share/cleo | ~/Library/Application Support/cleo | %LOCALAPPDATA%\\cleo\\Data\n * config: ~/.config/cleo | ~/Library/Preferences/cleo | %APPDATA%\\cleo\\Config\n * cache: ~/.cache/cleo | ~/Library/Caches/cleo | %LOCALAPPDATA%\\cleo\\Cache\n * log: ~/.local/state/cleo | ~/Library/Logs/cleo | %LOCALAPPDATA%\\cleo\\Log\n * temp: /tmp/<user>/cleo | /var/folders/.../cleo | %TEMP%\\cleo\n *\n * CLEO_HOME env var overrides the data path for backward compatibility\n * with existing ~/.cleo installations.\n */\n\nimport { arch, hostname, platform, release } from 'node:os';\nimport envPaths from 'env-paths';\n\nconst APP_NAME = 'cleo';\n\n/** OS-appropriate paths for CLEO's global directories. */\nexport interface PlatformPaths {\n /** User data dir. Override with CLEO_HOME env var. */\n data: string;\n /** User config dir (XDG_CONFIG_HOME / Library/Preferences / %APPDATA%). */\n config: string;\n /** User cache dir (XDG_CACHE_HOME / Library/Caches / %LOCALAPPDATA%). */\n cache: string;\n /** User log dir (XDG_STATE_HOME / Library/Logs / %LOCALAPPDATA%). */\n log: string;\n /** Temp dir for ephemeral files. */\n temp: string;\n}\n\n/** Immutable system information snapshot, captured once per process. */\nexport interface SystemInfo {\n platform: NodeJS.Platform;\n arch: string;\n release: string;\n hostname: string;\n nodeVersion: string;\n paths: PlatformPaths;\n}\n\nlet _sysInfo: SystemInfo | null = null;\n\n/**\n * Get OS-appropriate paths for CLEO's global directories.\n *\n * Reads fresh on every call \u2014 env-paths is fast (microseconds) and a\n * process-wide cache would skip XDG / APPDATA env-var changes that test\n * code and long-running CLI sessions legitimately make. CLEO_HOME env\n * var overrides the data path for backward compatibility.\n */\nexport function getPlatformPaths(): PlatformPaths {\n const ep = envPaths(APP_NAME, { suffix: '' });\n return {\n data: process.env['CLEO_HOME'] ?? ep.data,\n config: ep.config,\n cache: ep.cache,\n log: ep.log,\n temp: ep.temp,\n };\n}\n\n/**\n * Get a cached system information snapshot.\n * Captured once and reused for the process lifetime.\n * Useful for diagnostics, issue reports, and log enrichment.\n */\nexport function getSystemInfo(): SystemInfo {\n if (_sysInfo) return _sysInfo;\n\n _sysInfo = {\n platform: platform(),\n arch: arch(),\n release: release(),\n hostname: hostname(),\n nodeVersion: process.version,\n paths: getPlatformPaths(),\n };\n\n return _sysInfo;\n}\n\n/**\n * Invalidate the system info cache.\n * Use in tests that need a fresh platform snapshot.\n * @internal\n */\nexport function _resetPlatformPathsCache(): void {\n _sysInfo = null;\n}\n"],
5
+ "mappings": ";AAkBA,SAAS,MAAM,UAAU,UAAU,eAAe;AAClD,OAAO,cAAc;AAErB,IAAM,WAAW;AA0BjB,IAAI,WAA8B;AAU3B,SAAS,mBAAkC;AAChD,QAAM,KAAK,SAAS,UAAU,EAAE,QAAQ,GAAG,CAAC;AAC5C,SAAO;AAAA,IACL,MAAM,QAAQ,IAAI,WAAW,KAAK,GAAG;AAAA,IACrC,QAAQ,GAAG;AAAA,IACX,OAAO,GAAG;AAAA,IACV,KAAK,GAAG;AAAA,IACR,MAAM,GAAG;AAAA,EACX;AACF;AAOO,SAAS,gBAA4B;AAC1C,MAAI,SAAU,QAAO;AAErB,aAAW;AAAA,IACT,UAAU,SAAS;AAAA,IACnB,MAAM,KAAK;AAAA,IACX,SAAS,QAAQ;AAAA,IACjB,UAAU,SAAS;AAAA,IACnB,aAAa,QAAQ;AAAA,IACrB,OAAO,iBAAiB;AAAA,EAC1B;AAEA,SAAO;AACT;AAOO,SAAS,2BAAiC;AAC/C,aAAW;AACb;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/core",
3
- "version": "2026.4.98",
3
+ "version": "2026.4.100",
4
4
  "description": "CLEO core business logic kernel — tasks, sessions, memory, orchestration, lifecycle, with bundled SQLite store",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -111,6 +111,71 @@
111
111
  "import": "./dist/memory/brain-backfill.js",
112
112
  "require": "./dist/memory/brain-backfill.js"
113
113
  },
114
+ "./sentient": {
115
+ "types": "./dist/sentient/index.d.ts",
116
+ "import": "./dist/sentient/index.js",
117
+ "require": "./dist/sentient/index.js"
118
+ },
119
+ "./sentient/*": {
120
+ "types": "./dist/sentient/*.d.ts",
121
+ "import": "./dist/sentient/*.js",
122
+ "require": "./dist/sentient/*.js"
123
+ },
124
+ "./sentient/daemon.js": {
125
+ "types": "./dist/sentient/daemon.d.ts",
126
+ "import": "./dist/sentient/daemon.js",
127
+ "require": "./dist/sentient/daemon.js"
128
+ },
129
+ "./sentient/state.js": {
130
+ "types": "./dist/sentient/state.d.ts",
131
+ "import": "./dist/sentient/state.js",
132
+ "require": "./dist/sentient/state.js"
133
+ },
134
+ "./sentient/tick.js": {
135
+ "types": "./dist/sentient/tick.d.ts",
136
+ "import": "./dist/sentient/tick.js",
137
+ "require": "./dist/sentient/tick.js"
138
+ },
139
+ "./sentient/propose-tick.js": {
140
+ "types": "./dist/sentient/propose-tick.d.ts",
141
+ "import": "./dist/sentient/propose-tick.js",
142
+ "require": "./dist/sentient/propose-tick.js"
143
+ },
144
+ "./sentient/proposal-rate-limiter.js": {
145
+ "types": "./dist/sentient/proposal-rate-limiter.d.ts",
146
+ "import": "./dist/sentient/proposal-rate-limiter.js",
147
+ "require": "./dist/sentient/proposal-rate-limiter.js"
148
+ },
149
+ "./gc": {
150
+ "types": "./dist/gc/index.d.ts",
151
+ "import": "./dist/gc/index.js",
152
+ "require": "./dist/gc/index.js"
153
+ },
154
+ "./gc/*": {
155
+ "types": "./dist/gc/*.d.ts",
156
+ "import": "./dist/gc/*.js",
157
+ "require": "./dist/gc/*.js"
158
+ },
159
+ "./gc/daemon.js": {
160
+ "types": "./dist/gc/daemon.d.ts",
161
+ "import": "./dist/gc/daemon.js",
162
+ "require": "./dist/gc/daemon.js"
163
+ },
164
+ "./gc/runner.js": {
165
+ "types": "./dist/gc/runner.d.ts",
166
+ "import": "./dist/gc/runner.js",
167
+ "require": "./dist/gc/runner.js"
168
+ },
169
+ "./gc/state.js": {
170
+ "types": "./dist/gc/state.d.ts",
171
+ "import": "./dist/gc/state.js",
172
+ "require": "./dist/gc/state.js"
173
+ },
174
+ "./gc/transcript.js": {
175
+ "types": "./dist/gc/transcript.d.ts",
176
+ "import": "./dist/gc/transcript.js",
177
+ "require": "./dist/gc/transcript.js"
178
+ },
114
179
  "./system/platform-paths.js": {
115
180
  "types": "./dist/system/platform-paths.d.ts",
116
181
  "import": "./dist/system/platform-paths.js",
@@ -148,16 +213,18 @@
148
213
  "tree-sitter-ruby": "^0.23.1",
149
214
  "tree-sitter-rust": "0.23.1",
150
215
  "tree-sitter-typescript": "^0.23.2",
216
+ "check-disk-space": "^3.4.0",
217
+ "node-cron": "^4.2.1",
151
218
  "write-file-atomic": "^7.0.1",
152
219
  "yaml": "^2.8.3",
153
220
  "zod": "^4.3.6",
154
- "@cleocode/adapters": "2026.4.98",
155
- "@cleocode/agents": "2026.4.98",
156
- "@cleocode/caamp": "2026.4.98",
157
- "@cleocode/nexus": "2026.4.98",
158
- "@cleocode/lafs": "2026.4.98",
159
- "@cleocode/skills": "2026.4.98",
160
- "@cleocode/contracts": "2026.4.98"
221
+ "@cleocode/adapters": "2026.4.100",
222
+ "@cleocode/caamp": "2026.4.100",
223
+ "@cleocode/contracts": "2026.4.100",
224
+ "@cleocode/nexus": "2026.4.100",
225
+ "@cleocode/lafs": "2026.4.100",
226
+ "@cleocode/skills": "2026.4.100",
227
+ "@cleocode/agents": "2026.4.100"
161
228
  },
162
229
  "engines": {
163
230
  "node": ">=24.0.0"
@@ -174,6 +241,7 @@
174
241
  "src"
175
242
  ],
176
243
  "devDependencies": {
244
+ "@types/node-cron": "^3.0.11",
177
245
  "@types/proper-lockfile": "^4.1.4",
178
246
  "@types/tar": "^6.1.13",
179
247
  "@types/write-file-atomic": "^4.0.3",