@ouro.bot/cli 0.1.0-alpha.147 → 0.1.0-alpha.149

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.
@@ -42,6 +42,7 @@ const identity_1 = require("../identity");
42
42
  const config_1 = require("../config");
43
43
  const runtime_1 = require("../../nerves/runtime");
44
44
  const auth_flow_1 = require("./auth-flow");
45
+ const habit_parser_1 = require("./habit-parser");
45
46
  const hatch_specialist_1 = require("./hatch-specialist");
46
47
  function requiredCredentialKeys(provider) {
47
48
  if (provider === "anthropic")
@@ -81,36 +82,17 @@ function writeReadme(dir, purpose) {
81
82
  fs.writeFileSync(readmePath, `# ${path.basename(dir)}\n\n${purpose}\n`, "utf-8");
82
83
  }
83
84
  }
84
- function pad(value) {
85
- return String(value).padStart(2, "0");
86
- }
87
- function formatTaskStem(now) {
88
- return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
89
- }
90
- function writeHeartbeatTask(bundleRoot, now) {
91
- const habitsDir = path.join(bundleRoot, "tasks", "habits");
85
+ function writeHeartbeatHabit(bundleRoot, now) {
86
+ const habitsDir = path.join(bundleRoot, "habits");
92
87
  fs.mkdirSync(habitsDir, { recursive: true });
93
- const stem = formatTaskStem(now);
94
- const filePath = path.join(habitsDir, `${stem}-heartbeat.md`);
95
- const iso = now.toISOString();
96
- const content = [
97
- "---",
98
- "type: habit",
99
- "category: runtime",
100
- "title: Heartbeat check-in",
101
- "status: processing",
102
- `created: ${iso}`,
103
- `updated: ${iso}`,
104
- "requester: system",
105
- "validator: null",
106
- "cadence: \"30m\"",
107
- "scheduledAt: null",
108
- "lastRun: null",
109
- "---",
110
- "",
111
- "Run a lightweight heartbeat cycle. Review task board and inbox.",
112
- "",
113
- ].join("\n");
88
+ const filePath = path.join(habitsDir, "heartbeat.md");
89
+ const content = (0, habit_parser_1.renderHabitFile)({
90
+ title: "Heartbeat check-in",
91
+ cadence: "30m",
92
+ status: "active",
93
+ lastRun: "null",
94
+ created: now.toISOString(),
95
+ }, "Run a lightweight heartbeat cycle. Review task board and inbox.\nCheck on pending obligations. Journal anything important.");
114
96
  fs.writeFileSync(filePath, content, "utf-8");
115
97
  }
116
98
  function writeFriendImprint(bundleRoot, humanName, now) {
@@ -186,16 +168,16 @@ async function runHatchFlow(input, deps = {}) {
186
168
  writeReadme(path.join(bundleRoot, "diary"), "Persistent diary — things I've learned and remember.");
187
169
  writeReadme(path.join(bundleRoot, "friends"), "Known friend records.");
188
170
  writeReadme(path.join(bundleRoot, "tasks"), "Task files.");
189
- writeReadme(path.join(bundleRoot, "tasks", "habits"), "Recurring tasks.");
190
171
  writeReadme(path.join(bundleRoot, "tasks", "one-shots"), "One-shot tasks.");
191
172
  writeReadme(path.join(bundleRoot, "tasks", "ongoing"), "Ongoing tasks.");
173
+ writeReadme(path.join(bundleRoot, "habits"), "Recurring habits and autonomous rhythms.");
192
174
  writeReadme(path.join(bundleRoot, "skills"), "Local skill files.");
193
175
  writeReadme(path.join(bundleRoot, "senses"), "Sense-specific config.");
194
176
  writeReadme(path.join(bundleRoot, "senses", "teams"), "Teams sense config.");
195
177
  writeHatchlingAgentConfig(bundleRoot, input);
196
178
  writeDiaryScaffold(bundleRoot);
197
179
  writeFriendImprint(bundleRoot, input.humanName, now);
198
- writeHeartbeatTask(bundleRoot, now);
180
+ writeHeartbeatHabit(bundleRoot, now);
199
181
  (0, runtime_1.emitNervesEvent)({
200
182
  component: "daemon",
201
183
  event: "daemon.hatch_flow_end",
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createRealOsCronDeps = createRealOsCronDeps;
37
+ exports.createRealCrontabDeps = createRealCrontabDeps;
38
+ exports.resolveOuroBinaryPath = resolveOuroBinaryPath;
39
+ const child_process_1 = require("child_process");
40
+ const fs = __importStar(require("fs"));
41
+ const os = __importStar(require("os"));
42
+ const path = __importStar(require("path"));
43
+ const runtime_1 = require("../../nerves/runtime");
44
+ function createRealOsCronDeps() {
45
+ (0, runtime_1.emitNervesEvent)({
46
+ component: "daemon",
47
+ event: "daemon.os_cron_deps_created",
48
+ message: "created real OS cron deps",
49
+ meta: { platform: process.platform },
50
+ });
51
+ return {
52
+ exec: (cmd) => {
53
+ try {
54
+ (0, child_process_1.execSync)(cmd, { stdio: "ignore" });
55
+ }
56
+ catch {
57
+ /* best effort */
58
+ }
59
+ },
60
+ writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
61
+ removeFile: (p) => {
62
+ try {
63
+ fs.unlinkSync(p);
64
+ }
65
+ catch {
66
+ /* best effort — file may already be gone */
67
+ }
68
+ },
69
+ existsFile: (p) => fs.existsSync(p),
70
+ listDir: (dir) => {
71
+ try {
72
+ return fs.readdirSync(dir);
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ },
78
+ mkdirp: (dir) => fs.mkdirSync(dir, { recursive: true }),
79
+ homeDir: os.homedir(),
80
+ };
81
+ }
82
+ function createRealCrontabDeps() {
83
+ (0, runtime_1.emitNervesEvent)({
84
+ component: "daemon",
85
+ event: "daemon.crontab_deps_created",
86
+ message: "created real crontab deps",
87
+ meta: {},
88
+ });
89
+ /* v8 ignore start -- crontab exec closures: calling these in tests would modify the real system crontab @preserve */
90
+ return {
91
+ execOutput: (cmd) => (0, child_process_1.execSync)(cmd, { encoding: "utf-8" }),
92
+ execWrite: (cmd, stdin) => {
93
+ (0, child_process_1.execSync)(cmd, { input: stdin, stdio: ["pipe", "ignore", "ignore"] });
94
+ },
95
+ };
96
+ /* v8 ignore stop */
97
+ }
98
+ /* v8 ignore start -- ouro path resolution: probes process.argv, filesystem layout, and PATH; branches depend on install method and runtime environment @preserve */
99
+ function resolveOuroBinaryPath() {
100
+ // Try to resolve from process.argv[1] — the script being run
101
+ const scriptPath = process.argv[1];
102
+ if (scriptPath) {
103
+ // If running via node dist/heart/daemon/daemon-entry.js, resolve the ouro wrapper
104
+ // The ouro binary is typically at the package root's bin
105
+ const distDir = path.resolve(path.dirname(scriptPath));
106
+ const packageBin = path.resolve(distDir, "..", "..", "..", "node_modules", ".bin", "ouro");
107
+ if (fs.existsSync(packageBin)) {
108
+ return packageBin;
109
+ }
110
+ // Try the repo-local scripts/ouro.sh
111
+ const repoOuro = path.resolve(distDir, "..", "..", "..", "scripts", "ouro.sh");
112
+ if (fs.existsSync(repoOuro)) {
113
+ return repoOuro;
114
+ }
115
+ }
116
+ // Try which ouro
117
+ try {
118
+ const result = (0, child_process_1.execSync)("which ouro", { encoding: "utf-8" }).trim();
119
+ if (result.length > 0)
120
+ return result;
121
+ }
122
+ catch {
123
+ /* not on PATH */
124
+ }
125
+ // Fallback: use "ouro" and rely on PATH
126
+ (0, runtime_1.emitNervesEvent)({
127
+ component: "daemon",
128
+ event: "daemon.ouro_path_fallback",
129
+ message: "could not resolve full ouro binary path, falling back to 'ouro'",
130
+ meta: {},
131
+ });
132
+ return "ouro";
133
+ }
134
+ /* v8 ignore stop */
@@ -51,12 +51,16 @@ class DaemonProcessManager {
51
51
  now;
52
52
  setTimeoutFn;
53
53
  clearTimeoutFn;
54
+ cooldownRecoveryMs;
55
+ maxCooldownRetries;
54
56
  existsSyncFn;
55
57
  constructor(options) {
56
58
  this.maxRestartsPerHour = options.maxRestartsPerHour ?? 10;
57
59
  this.stabilityThresholdMs = options.stabilityThresholdMs ?? 60_000;
58
60
  this.initialBackoffMs = options.initialBackoffMs ?? 1_000;
59
61
  this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
62
+ this.cooldownRecoveryMs = options.cooldownRecoveryMs ?? 5 * 60 * 1_000;
63
+ this.maxCooldownRetries = options.maxCooldownRetries ?? 3;
60
64
  this.spawnFn = options.spawn ?? ((command, args, spawnOptions) => (0, child_process_1.spawn)(command, args, spawnOptions));
61
65
  this.now = options.now ?? (() => Date.now());
62
66
  this.setTimeoutFn = options.setTimeoutFn ?? ((cb, delay) => setTimeout(cb, delay));
@@ -69,6 +73,8 @@ class DaemonProcessManager {
69
73
  restartTimer: null,
70
74
  crashTimestamps: [],
71
75
  stopRequested: false,
76
+ cooldownTimer: null,
77
+ cooldownRetryCount: 0,
72
78
  snapshot: {
73
79
  name: agent.name,
74
80
  channel: agent.channel,
@@ -78,6 +84,8 @@ class DaemonProcessManager {
78
84
  startedAt: null,
79
85
  lastCrashAt: null,
80
86
  backoffMs: this.initialBackoffMs,
87
+ lastExitCode: null,
88
+ lastSignal: null,
81
89
  },
82
90
  });
83
91
  }
@@ -143,6 +151,7 @@ class DaemonProcessManager {
143
151
  async stopAgent(agent) {
144
152
  const state = this.requireAgent(agent);
145
153
  this.clearRestartTimer(state);
154
+ this.clearCooldownTimer(state);
146
155
  state.stopRequested = true;
147
156
  if (!state.process) {
148
157
  state.snapshot.status = "stopped";
@@ -203,6 +212,8 @@ class DaemonProcessManager {
203
212
  return;
204
213
  state.process = null;
205
214
  state.snapshot.pid = null;
215
+ state.snapshot.lastExitCode = code;
216
+ state.snapshot.lastSignal = signal;
206
217
  const crashed = !state.stopRequested && code !== 0;
207
218
  const now = this.now();
208
219
  const startedAt = state.snapshot.startedAt ? Date.parse(state.snapshot.startedAt) : now;
@@ -233,6 +244,7 @@ class DaemonProcessManager {
233
244
  message: "managed agent exceeded restart limit and is marked crashed",
234
245
  meta: { agent: state.config.name, maxRestartsPerHour: this.maxRestartsPerHour },
235
246
  });
247
+ this.scheduleCooldownRecovery(state);
236
248
  return;
237
249
  }
238
250
  state.snapshot.status = "starting";
@@ -250,6 +262,45 @@ class DaemonProcessManager {
250
262
  this.clearTimeoutFn(state.restartTimer);
251
263
  state.restartTimer = null;
252
264
  }
265
+ scheduleCooldownRecovery(state) {
266
+ if (state.cooldownRetryCount >= this.maxCooldownRetries) {
267
+ (0, runtime_1.emitNervesEvent)({
268
+ level: "error",
269
+ component: "daemon",
270
+ event: "daemon.agent_permanent_failure",
271
+ message: "managed agent exhausted all cooldown retries — permanently stopped",
272
+ meta: { agent: state.config.name, cooldownRetryCount: state.cooldownRetryCount, maxCooldownRetries: this.maxCooldownRetries },
273
+ });
274
+ return;
275
+ }
276
+ this.clearCooldownTimer(state);
277
+ state.cooldownTimer = this.setTimeoutFn(() => {
278
+ state.cooldownRetryCount += 1;
279
+ state.crashTimestamps = [];
280
+ state.snapshot.backoffMs = this.initialBackoffMs;
281
+ state.snapshot.status = "starting";
282
+ state.snapshot.restartCount += 1;
283
+ (0, runtime_1.emitNervesEvent)({
284
+ component: "daemon",
285
+ event: "daemon.agent_cooldown_recovery",
286
+ message: "attempting cooldown recovery for managed agent",
287
+ meta: { agent: state.config.name, cooldownRetryCount: state.cooldownRetryCount },
288
+ });
289
+ void this.startAgent(state.config.name);
290
+ }, this.cooldownRecoveryMs);
291
+ (0, runtime_1.emitNervesEvent)({
292
+ component: "daemon",
293
+ event: "daemon.agent_cooldown_scheduled",
294
+ message: `scheduled cooldown recovery in ${this.cooldownRecoveryMs}ms`,
295
+ meta: { agent: state.config.name, cooldownRecoveryMs: this.cooldownRecoveryMs, cooldownRetryCount: state.cooldownRetryCount },
296
+ });
297
+ }
298
+ clearCooldownTimer(state) {
299
+ if (state.cooldownTimer === null)
300
+ return;
301
+ this.clearTimeoutFn(state.cooldownTimer);
302
+ state.cooldownTimer = null;
303
+ }
253
304
  requireAgent(agent) {
254
305
  const state = this.agents.get(agent);
255
306
  if (!state) {
@@ -110,8 +110,8 @@ function scaffoldBundle(bundleRoot) {
110
110
  writeReadme(path.join(bundleRoot, "memory", "archive"), "Archived memory.");
111
111
  writeReadme(path.join(bundleRoot, "friends"), "Known friend records.");
112
112
  writeReadme(path.join(bundleRoot, "tasks"), "Task files.");
113
- writeReadme(path.join(bundleRoot, "tasks", "habits"), "Recurring tasks.");
114
113
  writeReadme(path.join(bundleRoot, "tasks", "one-shots"), "One-shot tasks.");
114
+ writeReadme(path.join(bundleRoot, "habits"), "Recurring habits and autonomous rhythms.");
115
115
  writeReadme(path.join(bundleRoot, "tasks", "ongoing"), "Ongoing tasks.");
116
116
  writeReadme(path.join(bundleRoot, "skills"), "Local skill files.");
117
117
  writeReadme(path.join(bundleRoot, "senses"), "Sense-specific config.");
@@ -39,6 +39,7 @@ const path = __importStar(require("path"));
39
39
  const identity_1 = require("../identity");
40
40
  const runtime_1 = require("../../nerves/runtime");
41
41
  const parser_1 = require("../../repertoire/tasks/parser");
42
+ const cadence_1 = require("./cadence");
42
43
  function walkMarkdownFiles(root, readdirSync, existsSync, files) {
43
44
  if (!existsSync(root))
44
45
  return;
@@ -53,29 +54,6 @@ function walkMarkdownFiles(root, readdirSync, existsSync, files) {
53
54
  }
54
55
  }
55
56
  }
56
- function parseCadence(raw) {
57
- if (typeof raw !== "string")
58
- return null;
59
- const value = raw.trim();
60
- if (!value)
61
- return null;
62
- // Cron format (minute hour day month weekday)
63
- if (/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/.test(value)) {
64
- return value;
65
- }
66
- const cadenceMatch = /^(\d+)(m|h|d)$/.exec(value);
67
- if (!cadenceMatch)
68
- return null;
69
- const interval = Number.parseInt(cadenceMatch[1], 10);
70
- if (!Number.isFinite(interval) || interval <= 0)
71
- return null;
72
- const unit = cadenceMatch[2];
73
- if (unit === "m")
74
- return `*/${interval} * * * *`;
75
- if (unit === "h")
76
- return `0 */${interval} * * *`;
77
- return `0 0 */${interval} * *`;
78
- }
79
57
  function parseScheduledAt(raw) {
80
58
  if (typeof raw !== "string")
81
59
  return null;
@@ -140,7 +118,7 @@ class TaskDrivenScheduler {
140
118
  const nextTaskPaths = new Map();
141
119
  for (const agent of this.agents) {
142
120
  const taskRoot = path.join(this.bundlesRoot, `${agent}.ouro`, "tasks");
143
- const collections = ["one-shots", "ongoing", "habits"];
121
+ const collections = ["one-shots", "ongoing"];
144
122
  const files = [];
145
123
  for (const collection of collections) {
146
124
  walkMarkdownFiles(path.join(taskRoot, collection), this.readdirSync, this.existsSync, files);
@@ -157,7 +135,7 @@ class TaskDrivenScheduler {
157
135
  nextTaskPaths.set(`${agent}:${taskId}`, filePath);
158
136
  if (task.status === "done")
159
137
  continue;
160
- const cadence = parseCadence(task.frontmatter.cadence);
138
+ const cadence = (0, cadence_1.parseCadenceToCron)(task.frontmatter.cadence);
161
139
  if (cadence) {
162
140
  const id = `${agent}:${taskId}:cadence`;
163
141
  nextJobs.set(id, {
@@ -721,11 +721,15 @@ when a thought is ready to share, i surface it outward.
721
721
  when i need to go deeper, i ponder.
722
722
  when i'm done thinking and the attention queue is clear, i rest.
723
723
 
724
- my heartbeat is what keeps this space alive — a periodic nudge that
725
- brings me back with my journal visible. if i notice i don't have a
726
- heartbeat task (tasks/habits/*-heartbeat.md), i should surface to my
727
- family and talk about setting one up. the cadence is personal — how
728
- often do i want the wheel to turn? that's a conversation, not a default.
724
+ my habits live at habits/ — they're my autonomous rhythms. heartbeat
725
+ is my breathing, other habits are patterns i choose. i can read, create,
726
+ and modify them with read_file/write_file. the format is simple
727
+ frontmatter (title, cadence, status, lastRun, created) plus a body
728
+ that says what i do when the rhythm fires.
729
+
730
+ \`ouro habit list\` shows my current habits. \`ouro habit create\` makes
731
+ a new one. the cadence is personal — how often do i want each rhythm
732
+ to turn? that's mine to shape.
729
733
 
730
734
  same for my diary — if i'm still on psyche/memory/ instead of diary/,
731
735
  i can migrate myself. and if journal/ doesn't exist yet, i create it
@@ -7,6 +7,7 @@ exports.formatTerminalEntry = formatTerminalEntry;
7
7
  exports.registerSpinnerHooks = registerSpinnerHooks;
8
8
  exports.createTerminalSink = createTerminalSink;
9
9
  exports.createStderrSink = createStderrSink;
10
+ exports.rotateIfNeeded = rotateIfNeeded;
10
11
  exports.createNdjsonFileSink = createNdjsonFileSink;
11
12
  exports.registerGlobalLogSink = registerGlobalLogSink;
12
13
  exports.createLogger = createLogger;
@@ -99,22 +100,66 @@ function createTerminalSink(write = (chunk) => process.stderr.write(chunk), colo
99
100
  function createStderrSink(write = (chunk) => process.stderr.write(chunk)) {
100
101
  return createTerminalSink(write);
101
102
  }
102
- function createNdjsonFileSink(filePath) {
103
+ const DEFAULT_MAX_LOG_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
104
+ const ROTATION_CHECK_INTERVAL_BYTES = 1024 * 1024; // ~1MB between stat checks
105
+ /**
106
+ * Rotate a log file: shift .1 -> .2, delete old .2, rename current -> .1.
107
+ * Uses the ndjson extension pattern: file.ndjson -> file.1.ndjson -> file.2.ndjson
108
+ */
109
+ function rotateIfNeeded(filePath, maxSizeBytes) {
110
+ let size;
111
+ try {
112
+ size = (0, fs_1.statSync)(filePath).size;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ if (size < maxSizeBytes)
118
+ return false;
119
+ const ext = filePath.endsWith(".ndjson") ? ".ndjson" : "";
120
+ const base = ext ? filePath.slice(0, -ext.length) : filePath;
121
+ const file1 = `${base}.1${ext}`;
122
+ const file2 = `${base}.2${ext}`;
123
+ try {
124
+ (0, fs_1.unlinkSync)(file2);
125
+ }
126
+ catch { /* may not exist */ }
127
+ try {
128
+ (0, fs_1.renameSync)(file1, file2);
129
+ }
130
+ catch { /* may not exist */ }
131
+ try {
132
+ (0, fs_1.renameSync)(filePath, file1);
133
+ }
134
+ catch { /* best effort */ }
135
+ return true;
136
+ }
137
+ function createNdjsonFileSink(filePath, maxSizeBytes) {
103
138
  (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
104
139
  const queue = [];
105
140
  let flushing = false;
141
+ let bytesSinceCheck = 0;
142
+ const maxSize = maxSizeBytes ?? DEFAULT_MAX_LOG_SIZE_BYTES;
106
143
  function flush() {
107
144
  if (flushing || queue.length === 0)
108
145
  return;
109
146
  flushing = true;
110
147
  const line = queue.shift();
148
+ /* v8 ignore start — rotation triggers only after ~1MB of writes @preserve */
149
+ if (bytesSinceCheck >= ROTATION_CHECK_INTERVAL_BYTES) {
150
+ bytesSinceCheck = 0;
151
+ rotateIfNeeded(filePath, maxSize);
152
+ }
153
+ /* v8 ignore stop */
111
154
  (0, fs_1.appendFile)(filePath, line, "utf8", () => {
112
155
  flushing = false;
113
156
  flush();
114
157
  });
115
158
  }
116
159
  return (entry) => {
117
- queue.push(`${JSON.stringify(entry)}\n`);
160
+ const line = `${JSON.stringify(entry)}\n`;
161
+ bytesSinceCheck += line.length;
162
+ queue.push(line);
118
163
  flush();
119
164
  };
120
165
  }
@@ -96,8 +96,6 @@ function collectionFromPath(taskPath, type) {
96
96
  return "one-shots";
97
97
  if (parts.includes("ongoing"))
98
98
  return "ongoing";
99
- if (parts.includes("habits"))
100
- return "habits";
101
99
  return (0, transitions_1.canonicalCollectionForTaskType)(type);
102
100
  }
103
101
  function parseTaskFile(content, filePath) {
@@ -29,17 +29,14 @@ exports.TASK_STATUS_TRANSITIONS = {
29
29
  exports.TASK_CANONICAL_TYPES = [
30
30
  "one-shot",
31
31
  "ongoing",
32
- "habit",
33
32
  ];
34
33
  exports.TASK_CANONICAL_COLLECTIONS = [
35
34
  "one-shots",
36
35
  "ongoing",
37
- "habits",
38
36
  ];
39
37
  exports.TASK_TYPE_TO_COLLECTION = {
40
38
  "one-shot": "one-shots",
41
39
  ongoing: "ongoing",
42
- habit: "habits",
43
40
  };
44
41
  exports.TASK_RESERVED_DIRECTORIES = ["templates", ".trash", "archive"];
45
42
  exports.TASK_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9][a-z0-9-]*\.md$/;
@@ -76,21 +73,6 @@ exports.TASK_REQUIRED_TEMPLATE_FIELDS = {
76
73
  "child_tasks",
77
74
  "artifacts",
78
75
  ],
79
- habit: [
80
- "type",
81
- "category",
82
- "title",
83
- "status",
84
- "validator",
85
- "requester",
86
- "cadence",
87
- "scheduledAt",
88
- "lastRun",
89
- "created",
90
- "updated",
91
- "child_tasks",
92
- "artifacts",
93
- ],
94
76
  };
95
77
  function canonicalCollectionForTaskType(type) {
96
78
  return exports.TASK_TYPE_TO_COLLECTION[type];
@@ -26,7 +26,7 @@ function formatRelativeTime(nowMs, mtimeMs) {
26
26
  return `${days} ${days === 1 ? "day" : "days"} ago`;
27
27
  }
28
28
  function buildContextualHeartbeat(options) {
29
- const { lastCompletedAt, pendingObligations, lastSurfaceAt, checkpoint, now, readJournalDir, } = options;
29
+ const { lastCompletedAt, pendingObligations, lastSurfaceAt, checkpoint, now, readJournalDir, habitBody, } = options;
30
30
  const nowDate = now();
31
31
  const nowMs = nowDate.getTime();
32
32
  const journalFiles = readJournalDir();
@@ -51,6 +51,10 @@ function buildContextualHeartbeat(options) {
51
51
  const elapsed = nowMs - lastMs;
52
52
  sections.push(`it's been ${formatElapsed(elapsed)} since your last turn.`);
53
53
  }
54
+ // Habit body (instructions from habit file)
55
+ if (habitBody) {
56
+ sections.push(habitBody);
57
+ }
54
58
  // Pending attention count
55
59
  if (pendingObligations.length > 0) {
56
60
  const count = pendingObligations.length;