@cleocode/core 2026.4.98 → 2026.4.99

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 (59) 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/index.d.ts +14 -0
  6. package/dist/gc/index.d.ts.map +1 -0
  7. package/dist/gc/runner.d.ts +132 -0
  8. package/dist/gc/runner.d.ts.map +1 -0
  9. package/dist/gc/state.d.ts +94 -0
  10. package/dist/gc/state.d.ts.map +1 -0
  11. package/dist/gc/transcript.d.ts +130 -0
  12. package/dist/gc/transcript.d.ts.map +1 -0
  13. package/dist/sentient/daemon-entry.d.ts +11 -0
  14. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  15. package/dist/sentient/daemon.d.ts +160 -0
  16. package/dist/sentient/daemon.d.ts.map +1 -0
  17. package/dist/sentient/index.d.ts +18 -0
  18. package/dist/sentient/index.d.ts.map +1 -0
  19. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  20. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  21. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  22. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  23. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  24. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  25. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  26. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  27. package/dist/sentient/propose-tick.d.ts +105 -0
  28. package/dist/sentient/propose-tick.d.ts.map +1 -0
  29. package/dist/sentient/state.d.ts +143 -0
  30. package/dist/sentient/state.d.ts.map +1 -0
  31. package/dist/sentient/tick.d.ts +193 -0
  32. package/dist/sentient/tick.d.ts.map +1 -0
  33. package/package.json +76 -8
  34. package/src/gc/__tests__/runner.test.ts +367 -0
  35. package/src/gc/__tests__/state.test.ts +169 -0
  36. package/src/gc/__tests__/transcript.test.ts +371 -0
  37. package/src/gc/daemon-entry.ts +26 -0
  38. package/src/gc/daemon.ts +251 -0
  39. package/src/gc/index.ts +14 -0
  40. package/src/gc/runner.ts +378 -0
  41. package/src/gc/state.ts +140 -0
  42. package/src/gc/transcript.ts +380 -0
  43. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  44. package/src/sentient/__tests__/daemon.test.ts +472 -0
  45. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  46. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  47. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  48. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  49. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  50. package/src/sentient/daemon-entry.ts +20 -0
  51. package/src/sentient/daemon.ts +471 -0
  52. package/src/sentient/index.ts +18 -0
  53. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  54. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  55. package/src/sentient/ingesters/test-ingester.ts +205 -0
  56. package/src/sentient/proposal-rate-limiter.ts +172 -0
  57. package/src/sentient/propose-tick.ts +415 -0
  58. package/src/sentient/state.ts +229 -0
  59. package/src/sentient/tick.ts +688 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tests for the Test ingester.
3
+ *
4
+ * Uses real tmp directories for file I/O — no mocks.
5
+ *
6
+ * @task T1008
7
+ */
8
+
9
+ import { mkdtempSync as fsMkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
13
+ import {
14
+ COVERAGE_SUMMARY_PATH,
15
+ GATES_JSONL_PATH,
16
+ runTestIngester,
17
+ TEST_BASE_WEIGHT,
18
+ } from '../ingesters/test-ingester.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ let tmpDir: string;
25
+
26
+ beforeEach(() => {
27
+ tmpDir = fsMkdtempSync(join(tmpdir(), 'cleo-test-ingester-'));
28
+ mkdirSync(join(tmpDir, '.cleo', 'audit'), { recursive: true });
29
+ });
30
+
31
+ afterEach(() => {
32
+ rmSync(tmpDir, { recursive: true, force: true });
33
+ });
34
+
35
+ function writeGates(lines: object[]) {
36
+ writeFileSync(
37
+ join(tmpDir, GATES_JSONL_PATH),
38
+ lines.map((l) => JSON.stringify(l)).join('\n'),
39
+ 'utf-8',
40
+ );
41
+ }
42
+
43
+ function writeCoverage(summary: object) {
44
+ writeFileSync(join(tmpDir, COVERAGE_SUMMARY_PATH), JSON.stringify(summary), 'utf-8');
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // runTestIngester
49
+ // ---------------------------------------------------------------------------
50
+
51
+ describe('runTestIngester', () => {
52
+ it('returns empty array when gates.jsonl is absent', () => {
53
+ expect(runTestIngester(tmpDir)).toEqual([]);
54
+ });
55
+
56
+ it('parses gates.jsonl and emits one candidate per task with failCount > 0', () => {
57
+ writeGates([
58
+ { taskId: 'T100', gate: 'testsPassed', failCount: 2 },
59
+ { taskId: 'T101', gate: 'qaPassed', failCount: 0 },
60
+ { taskId: 'T102', gate: 'implemented', failCount: 1 },
61
+ ]);
62
+ const results = runTestIngester(tmpDir);
63
+ expect(results).toHaveLength(2);
64
+ expect(results.some((r) => r.sourceId === 'T100.testsPassed')).toBe(true);
65
+ expect(results.some((r) => r.sourceId === 'T102.implemented')).toBe(true);
66
+ });
67
+
68
+ it('returns empty array for Source B when coverage-summary.json is absent (no error)', () => {
69
+ // gates.jsonl also absent — should get empty with no throw
70
+ expect(() => runTestIngester(tmpDir)).not.toThrow();
71
+ expect(runTestIngester(tmpDir)).toEqual([]);
72
+ });
73
+
74
+ it('coverage source: emits candidate for file with lines.pct < 80', () => {
75
+ writeCoverage({
76
+ 'src/utils.ts': { lines: { pct: 55 } },
77
+ });
78
+ const results = runTestIngester(tmpDir);
79
+ expect(results.some((r) => r.sourceId === 'src/utils.ts')).toBe(true);
80
+ });
81
+
82
+ it('coverage source: does NOT emit candidate for file with lines.pct >= 80', () => {
83
+ writeCoverage({
84
+ 'src/utils.ts': { lines: { pct: 90 } },
85
+ });
86
+ const results = runTestIngester(tmpDir);
87
+ expect(results.some((r) => r.sourceId === 'src/utils.ts')).toBe(false);
88
+ });
89
+
90
+ it('title format for gate-fail candidate matches [T2-TEST] template', () => {
91
+ writeGates([{ taskId: 'T100', gate: 'testsPassed', failCount: 3 }]);
92
+ const results = runTestIngester(tmpDir);
93
+ expect(results[0]?.title).toMatch(/^\[T2-TEST\]/);
94
+ expect(results[0]?.title).toBe('[T2-TEST] Fix flaky gate: T100.testsPassed');
95
+ });
96
+
97
+ it('all candidates have source="test" and weight=TEST_BASE_WEIGHT', () => {
98
+ writeGates([{ taskId: 'T100', gate: 'testsPassed', failCount: 1 }]);
99
+ writeCoverage({ 'src/foo.ts': { lines: { pct: 60 } } });
100
+ const results = runTestIngester(tmpDir);
101
+ expect(results.every((r) => r.source === 'test')).toBe(true);
102
+ expect(results.every((r) => r.weight === TEST_BASE_WEIGHT)).toBe(true);
103
+ });
104
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Sentient Daemon Entry Point — spawned by `spawnSentientDaemon()`.
3
+ *
4
+ * Runs as a detached background process. Receives the project root as
5
+ * argv[2]. Does NOT import the CLI shim — only the sentient/ subtree.
6
+ *
7
+ * @see sentient/daemon.ts for spawn logic
8
+ * @task T946
9
+ */
10
+
11
+ import { cwd } from 'node:process';
12
+ import { bootstrapDaemon } from './daemon.js';
13
+
14
+ const projectRoot = process.argv[2] ?? cwd();
15
+
16
+ bootstrapDaemon(projectRoot).catch((err: unknown) => {
17
+ const message = err instanceof Error ? err.message : String(err);
18
+ process.stderr.write(`[CLEO SENTIENT] Fatal daemon error: ${message}\n`);
19
+ process.exit(1);
20
+ });
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Sentient Daemon — Tier-1 autonomous loop sidecar.
3
+ *
4
+ * Runs as a detached Node.js process and executes `runTick()` every 5
5
+ * minutes. Mirrors the gc/daemon.ts sidecar pattern (ADR-047) and honours
6
+ * the worktree protocol — all state lives under the project's `.cleo/`.
7
+ *
8
+ * Scoped IN (this module):
9
+ * - Tier-1 execution of unblocked tasks via `cleo orchestrate spawn`
10
+ * - Kill-switch with re-check at every tick checkpoint
11
+ * - Advisory locking via an OS-level lockfile (two daemons cannot coexist)
12
+ * - Stuck detection + self-pause on stuck-rate threshold
13
+ * - fs.watch-based fast kill propagation
14
+ *
15
+ * Scoped OUT (separate epics):
16
+ * - Tier-2 proposal queue (`cleo propose` / status='proposed' generation)
17
+ * - Tier-3 sandbox auto-merge (requires agent-in-container infra)
18
+ * - Ed25519 signing of receipts (handled by Agent B2 llmtxt/identity wiring)
19
+ *
20
+ * @see ADR-054 — Sentient Loop Tier-1
21
+ * @task T946
22
+ */
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import type { FSWatcher } from 'node:fs';
26
+ import { createWriteStream, constants as fsConstants, watch } from 'node:fs';
27
+ import { type FileHandle, open as fsOpen, mkdir } from 'node:fs/promises';
28
+ import { join } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import cron from 'node-cron';
31
+ import { type ProposeTickOptions, safeRunProposeTick } from './propose-tick.js';
32
+ import { patchSentientState, readSentientState, type SentientState } from './state.js';
33
+ import { safeRunTick, type TickOptions } from './tick.js';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Constants
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Relative subpath under a project root where sentient state lives. */
40
+ export const SENTIENT_STATE_FILE = '.cleo/sentient-state.json' as const;
41
+
42
+ /** Relative subpath for the daemon lockfile. */
43
+ export const SENTIENT_LOCK_FILE = '.cleo/sentient.lock' as const;
44
+
45
+ /** Cron expression: every 5 minutes (Tier-1 tick). */
46
+ export const SENTIENT_CRON_EXPR = '*/5 * * * *';
47
+
48
+ /**
49
+ * Cron expression: every 2 hours (Tier-2 propose tick).
50
+ *
51
+ * Separate from the Tier-1 cron to avoid proposal flooding.
52
+ * Only fires when `tier2Enabled = true` in sentient-state.json.
53
+ *
54
+ * @task T1008
55
+ */
56
+ export const SENTIENT_PROPOSE_CRON_EXPR = '0 */2 * * *';
57
+
58
+ /** Subdirectory for daemon logs. */
59
+ export const SENTIENT_LOG_DIR = '.cleo/logs' as const;
60
+
61
+ /** Log filename (stdout). */
62
+ export const SENTIENT_LOG = 'sentient.log' as const;
63
+
64
+ /** Log filename (stderr). */
65
+ export const SENTIENT_ERR = 'sentient.err' as const;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Advisory lock
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** Handle to an active advisory lock. */
72
+ export interface LockHandle {
73
+ /** Absolute path to the lockfile. */
74
+ path: string;
75
+ /** Underlying file handle held exclusively by this process. */
76
+ handle: FileHandle;
77
+ }
78
+
79
+ /**
80
+ * Acquire an exclusive advisory lock on the sentient lockfile.
81
+ *
82
+ * Uses `fs.open` with `O_CREAT | O_EXCL` semantics — if the file already
83
+ * exists AND its recorded pid is alive, acquisition fails. Stale lockfiles
84
+ * (pid dead) are reclaimed automatically.
85
+ *
86
+ * @param lockPath - Absolute path to `.cleo/sentient.lock`
87
+ * @returns Lock handle, or null if lock is held by a live process
88
+ */
89
+ export async function acquireLock(lockPath: string): Promise<LockHandle | null> {
90
+ await mkdir(join(lockPath, '..'), { recursive: true });
91
+
92
+ // First attempt: atomic create with O_EXCL.
93
+ try {
94
+ const handle = await fsOpen(
95
+ lockPath,
96
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR,
97
+ 0o644,
98
+ );
99
+ await handle.writeFile(String(process.pid), 'utf-8');
100
+ return { path: lockPath, handle };
101
+ } catch (err) {
102
+ const code = (err as NodeJS.ErrnoException).code;
103
+ if (code !== 'EEXIST') throw err;
104
+ }
105
+
106
+ // Lockfile exists. Check if the recorded pid is alive.
107
+ let existing: FileHandle | null = null;
108
+ try {
109
+ existing = await fsOpen(lockPath, fsConstants.O_RDWR);
110
+ const buf = await existing.readFile({ encoding: 'utf-8' });
111
+ const recordedPid = Number.parseInt(buf.trim(), 10);
112
+ if (Number.isFinite(recordedPid) && recordedPid > 0) {
113
+ try {
114
+ process.kill(recordedPid, 0);
115
+ // Process alive — lock is held.
116
+ await existing.close();
117
+ return null;
118
+ } catch {
119
+ // Process dead — fall through to reclaim.
120
+ }
121
+ }
122
+ // Reclaim: truncate, rewind, write our pid, keep the handle.
123
+ await existing.truncate(0);
124
+ const pidBytes = Buffer.from(String(process.pid), 'utf-8');
125
+ await existing.write(pidBytes, 0, pidBytes.length, 0);
126
+ return { path: lockPath, handle: existing };
127
+ } catch (err) {
128
+ if (existing) {
129
+ try {
130
+ await existing.close();
131
+ } catch {
132
+ // ignore
133
+ }
134
+ }
135
+ throw err;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Release an advisory lock acquired via {@link acquireLock}.
141
+ * Does NOT remove the lockfile — the pid inside is a useful diagnostic.
142
+ */
143
+ export async function releaseLock(lock: LockHandle): Promise<void> {
144
+ try {
145
+ await lock.handle.close();
146
+ } catch {
147
+ // ignore
148
+ }
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Daemon bootstrap
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Bootstrap the sentient daemon process.
157
+ *
158
+ * Steps:
159
+ * 1. Acquire advisory lock (fail fast if another daemon is running)
160
+ * 2. Persist our pid + startedAt to state.json
161
+ * 3. Watch state.json for killSwitch changes (fast propagation)
162
+ * 4. Register a SIGTERM handler for graceful shutdown
163
+ * 5. Schedule cron with noOverlap so long ticks don't stack
164
+ *
165
+ * @param projectRoot - Absolute path to the project (contains `.cleo/`)
166
+ */
167
+ export async function bootstrapDaemon(projectRoot: string): Promise<void> {
168
+ const statePath = join(projectRoot, SENTIENT_STATE_FILE);
169
+ const lockPath = join(projectRoot, SENTIENT_LOCK_FILE);
170
+
171
+ const lock = await acquireLock(lockPath);
172
+ if (!lock) {
173
+ process.stderr.write(`[CLEO SENTIENT] lock acquisition failed — another daemon is running\n`);
174
+ process.exit(2);
175
+ }
176
+
177
+ // Reset pid + counters baseline for this run.
178
+ await patchSentientState(statePath, {
179
+ pid: process.pid,
180
+ startedAt: new Date().toISOString(),
181
+ // Clear killSwitch on boot only if owner did not explicitly leave it set.
182
+ // We preserve it here: re-starting a killed daemon must not silently
183
+ // resume. Owner explicitly clears via `cleo sentient resume`.
184
+ });
185
+
186
+ // fs.watch on state file — lets us surface kill very quickly.
187
+ let watcher: FSWatcher | null = null;
188
+ try {
189
+ watcher = watch(statePath, { persistent: false }, () => {
190
+ // Just log — actual kill-switch check happens inside each tick. The
191
+ // file watcher exists so that an active tick can notice flipping
192
+ // without waiting for the 5-min cadence. Ticks re-read state at every
193
+ // checkpoint (Round 2 audit §1).
194
+ });
195
+ } catch {
196
+ watcher = null;
197
+ }
198
+
199
+ // Graceful shutdown.
200
+ const shutdown = async (reason: string): Promise<void> => {
201
+ try {
202
+ watcher?.close();
203
+ } catch {
204
+ // ignore
205
+ }
206
+ try {
207
+ await patchSentientState(statePath, {
208
+ pid: null,
209
+ killSwitchReason: reason,
210
+ });
211
+ } catch {
212
+ // ignore
213
+ }
214
+ try {
215
+ await releaseLock(lock);
216
+ } catch {
217
+ // ignore
218
+ }
219
+ process.exit(0);
220
+ };
221
+ process.on('SIGTERM', () => {
222
+ void shutdown('SIGTERM');
223
+ });
224
+ process.on('SIGINT', () => {
225
+ void shutdown('SIGINT');
226
+ });
227
+
228
+ // Kick off one tick immediately, then schedule cron.
229
+ const tickOptions: TickOptions = { projectRoot, statePath };
230
+ const outcome = await safeRunTick(tickOptions);
231
+ process.stdout.write(
232
+ `[CLEO SENTIENT] boot tick: ${outcome.kind} ` +
233
+ `(task=${outcome.taskId ?? 'n/a'}) ${outcome.detail}\n`,
234
+ );
235
+
236
+ // Tier-1: every 5 minutes
237
+ cron.schedule(
238
+ SENTIENT_CRON_EXPR,
239
+ async () => {
240
+ const result = await safeRunTick(tickOptions);
241
+ process.stdout.write(
242
+ `[CLEO SENTIENT] tick: ${result.kind} ` +
243
+ `(task=${result.taskId ?? 'n/a'}) ${result.detail}\n`,
244
+ );
245
+ },
246
+ {
247
+ timezone: 'UTC',
248
+ noOverlap: true,
249
+ name: 'cleo-sentient',
250
+ },
251
+ );
252
+
253
+ // Tier-2: every 2 hours (only when tier2Enabled = true)
254
+ // Runs under the same advisory lock as the Tier-1 cron — the lock is held
255
+ // for the lifetime of the daemon process, so both crons run inside it.
256
+ const proposeOptions: ProposeTickOptions = { projectRoot, statePath };
257
+ cron.schedule(
258
+ SENTIENT_PROPOSE_CRON_EXPR,
259
+ async () => {
260
+ const state = await readSentientState(statePath);
261
+ if (!state.tier2Enabled) return;
262
+ const result = await safeRunProposeTick(proposeOptions);
263
+ process.stdout.write(
264
+ `[CLEO SENTIENT T2] propose: ${result.kind} ` +
265
+ `(written=${result.written}, count=${result.count}) ${result.detail}\n`,
266
+ );
267
+ },
268
+ {
269
+ timezone: 'UTC',
270
+ noOverlap: true,
271
+ name: 'cleo-sentient-propose',
272
+ },
273
+ );
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Spawn helpers (parent-process side)
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /** Outcome of {@link spawnSentientDaemon}. */
281
+ export interface SpawnDaemonResult {
282
+ /** PID of the spawned daemon. */
283
+ pid: number;
284
+ /** Absolute path to the .cleo/sentient-state.json file. */
285
+ statePath: string;
286
+ /** Absolute path to the log file. */
287
+ logPath: string;
288
+ }
289
+
290
+ /**
291
+ * Spawn the sentient daemon as a detached background process.
292
+ *
293
+ * All three T751 §2.2 requirements:
294
+ * 1. `detached: true` — process-group leader survives parent exit
295
+ * 2. File-based stdio — no TTY inheritance
296
+ * 3. `child.unref()` — parent CLI returns immediately
297
+ *
298
+ * @param projectRoot - Absolute path to the project root (contains `.cleo/`)
299
+ * @returns PID + log paths
300
+ */
301
+ export async function spawnSentientDaemon(projectRoot: string): Promise<SpawnDaemonResult> {
302
+ const logsDir = join(projectRoot, SENTIENT_LOG_DIR);
303
+ await mkdir(logsDir, { recursive: true });
304
+
305
+ const logPath = join(logsDir, SENTIENT_LOG);
306
+ const errPath = join(logsDir, SENTIENT_ERR);
307
+
308
+ const outStream = createWriteStream(logPath, { flags: 'a' });
309
+ const errStream = createWriteStream(errPath, { flags: 'a' });
310
+
311
+ // Resolve daemon-entry.js in the compiled output (sibling to this module).
312
+ const daemonEntry = join(fileURLToPath(import.meta.url), '..', 'daemon-entry.js');
313
+
314
+ const child = spawn(process.execPath, [daemonEntry, projectRoot], {
315
+ detached: true,
316
+ stdio: ['ignore', outStream, errStream],
317
+ env: { ...process.env, CLEO_SENTIENT_DAEMON: '1' },
318
+ });
319
+
320
+ child.unref();
321
+
322
+ const pid = child.pid ?? 0;
323
+ const statePath = join(projectRoot, SENTIENT_STATE_FILE);
324
+
325
+ // Pre-persist our pid so subsequent `cleo sentient status` calls can find it
326
+ // even before the child writes its own pid.
327
+ await patchSentientState(statePath, {
328
+ pid,
329
+ startedAt: new Date().toISOString(),
330
+ });
331
+
332
+ return { pid, statePath, logPath };
333
+ }
334
+
335
+ /** Outcome of {@link stopSentientDaemon}. */
336
+ export interface StopDaemonResult {
337
+ /** Whether the stop signal was delivered. */
338
+ stopped: boolean;
339
+ /** Last known pid; null if no pid was recorded. */
340
+ pid: number | null;
341
+ /** Human-readable reason. */
342
+ reason: string;
343
+ }
344
+
345
+ /**
346
+ * Stop the sentient daemon.
347
+ *
348
+ * Flips killSwitch=true FIRST (so an in-flight tick notices on its next
349
+ * checkpoint re-read), then sends SIGTERM. This gives the daemon a fast,
350
+ * graceful shutdown path even during a long-running spawn.
351
+ *
352
+ * @param projectRoot - Absolute path to the project root
353
+ * @param reason - Optional reason stored on state file for diagnostics
354
+ * @returns Stop result
355
+ */
356
+ export async function stopSentientDaemon(
357
+ projectRoot: string,
358
+ reason = 'cleo sentient stop',
359
+ ): Promise<StopDaemonResult> {
360
+ const statePath = join(projectRoot, SENTIENT_STATE_FILE);
361
+ const state = await readSentientState(statePath);
362
+
363
+ // Flip killSwitch before signalling so an in-progress tick exits on the
364
+ // next checkpoint, even if SIGTERM is delayed or lost.
365
+ await patchSentientState(statePath, {
366
+ killSwitch: true,
367
+ killSwitchReason: reason,
368
+ });
369
+
370
+ const pid = state.pid;
371
+ if (!pid) {
372
+ return {
373
+ stopped: false,
374
+ pid: null,
375
+ reason: 'killSwitch set; no daemon pid recorded (no active process to signal)',
376
+ };
377
+ }
378
+
379
+ try {
380
+ process.kill(pid, 0);
381
+ } catch {
382
+ await patchSentientState(statePath, { pid: null });
383
+ return {
384
+ stopped: true,
385
+ pid,
386
+ reason: `killSwitch set; daemon pid ${pid} was already dead (cleared)`,
387
+ };
388
+ }
389
+
390
+ try {
391
+ process.kill(pid, 'SIGTERM');
392
+ return { stopped: true, pid, reason: `killSwitch set + SIGTERM delivered to pid ${pid}` };
393
+ } catch (err) {
394
+ const message = err instanceof Error ? err.message : String(err);
395
+ return {
396
+ stopped: false,
397
+ pid,
398
+ reason: `killSwitch set but SIGTERM failed: ${message}`,
399
+ };
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Clear the kill switch so the cron schedule resumes executing ticks.
405
+ *
406
+ * Does NOT restart the daemon process — that is the caller's responsibility
407
+ * via `cleo sentient start` if the process itself exited.
408
+ *
409
+ * @param projectRoot - Absolute path to the project root
410
+ */
411
+ export async function resumeSentientDaemon(projectRoot: string): Promise<SentientState> {
412
+ const statePath = join(projectRoot, SENTIENT_STATE_FILE);
413
+ return patchSentientState(statePath, {
414
+ killSwitch: false,
415
+ killSwitchReason: null,
416
+ });
417
+ }
418
+
419
+ /** Status snapshot returned by {@link getSentientDaemonStatus}. */
420
+ export interface SentientStatus {
421
+ /** Whether the pid on file is currently alive. */
422
+ running: boolean;
423
+ /** Recorded pid (null when never started or cleared on stop). */
424
+ pid: number | null;
425
+ /** ISO-8601 timestamp of last start. */
426
+ startedAt: string | null;
427
+ /** ISO-8601 timestamp of the last completed tick. */
428
+ lastTickAt: string | null;
429
+ /** Kill-switch state. */
430
+ killSwitch: boolean;
431
+ /** Reason supplied with the last kill. */
432
+ killSwitchReason: string | null;
433
+ /** Rolling stats. */
434
+ stats: SentientState['stats'];
435
+ /** Number of currently-stuck tasks. */
436
+ stuckCount: number;
437
+ /** Currently active task id (set mid-tick). */
438
+ activeTaskId: string | null;
439
+ }
440
+
441
+ /**
442
+ * Return a diagnostic snapshot for `cleo sentient status`.
443
+ *
444
+ * @param projectRoot - Absolute path to the project root
445
+ */
446
+ export async function getSentientDaemonStatus(projectRoot: string): Promise<SentientStatus> {
447
+ const statePath = join(projectRoot, SENTIENT_STATE_FILE);
448
+ const state = await readSentientState(statePath);
449
+
450
+ let running = false;
451
+ if (state.pid) {
452
+ try {
453
+ process.kill(state.pid, 0);
454
+ running = true;
455
+ } catch {
456
+ running = false;
457
+ }
458
+ }
459
+
460
+ return {
461
+ running,
462
+ pid: running ? state.pid : null,
463
+ startedAt: state.startedAt,
464
+ lastTickAt: state.lastTickAt,
465
+ killSwitch: state.killSwitch,
466
+ killSwitchReason: state.killSwitchReason,
467
+ stats: state.stats,
468
+ stuckCount: Object.keys(state.stuckTasks).length,
469
+ activeTaskId: state.activeTaskId,
470
+ };
471
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @cleocode/core/sentient — Tier-1/Tier-2 sentient daemon public API.
3
+ *
4
+ * Provides the autonomous loop logic: tick execution, Tier-2 proposal
5
+ * generation, state management, rate limiting, and ingesters.
6
+ *
7
+ * @see ADR-054 — Sentient Loop Tier-1
8
+ * @package @cleocode/core
9
+ */
10
+
11
+ export * from './daemon.js';
12
+ export * from './ingesters/brain-ingester.js';
13
+ export * from './ingesters/nexus-ingester.js';
14
+ export * from './ingesters/test-ingester.js';
15
+ export * from './proposal-rate-limiter.js';
16
+ export * from './propose-tick.js';
17
+ export * from './state.js';
18
+ export * from './tick.js';