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

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.
package/changelog.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.148",
6
+ "changes": [
7
+ "Habits extracted from task system as first-class bundle concept at habits/ (peer to diary/, friends/, tasks/).",
8
+ "Name-based identity: heartbeat.md, not YYYY-MM-DD-HHMM-heartbeat.md. Simple schema: title, cadence, status (active/paused), body, lastRun, created.",
9
+ "HabitScheduler with OS cron (LaunchdCronManager/CrontabCronManager): each habit fires independently via its own cron entry.",
10
+ "Event-driven discovery: fs.watch + CLI notify trigger reconcile. No polling.",
11
+ "Worker rerun queue replaces single-slot mechanism, prevents dropped pokes. Each habit turn includes also-due line for agent triage.",
12
+ "Contextual heartbeat includes habit body: agent sees own instructions when heartbeat fires.",
13
+ "Daemon auto-migration from tasks/habits/ on startup. Strips timestamp prefix, maps statuses, removes task-only fields.",
14
+ "ouro habit list and ouro habit create CLI commands. Parse error reporting via nerves events + inner dialog nudge.",
15
+ "Metacognitive framing teaches agent about habits/ directory, rhythms, schema, read_file/write_file, and CLI commands.",
16
+ "HeartbeatTimer deleted, replaced by HabitScheduler. habit type removed from task system entirely."
17
+ ]
18
+ },
4
19
  {
5
20
  "version": "0.1.0-alpha.147",
6
21
  "changes": [
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_CADENCE_MS = void 0;
4
+ exports.parseCadenceToCron = parseCadenceToCron;
5
+ exports.parseCadenceToMs = parseCadenceToMs;
6
+ const runtime_1 = require("../../nerves/runtime");
7
+ exports.DEFAULT_CADENCE_MS = 30 * 60 * 1000; // 30 minutes
8
+ /**
9
+ * Parse a cadence shorthand (e.g. "30m", "2h", "1d") into a cron string.
10
+ * Also accepts raw cron strings (5 space-separated fields) and passes them through.
11
+ * Returns null for invalid input.
12
+ */
13
+ function parseCadenceToCron(raw) {
14
+ if (typeof raw !== "string")
15
+ return null;
16
+ const value = raw.trim();
17
+ if (!value)
18
+ return null;
19
+ // Cron format (minute hour day month weekday)
20
+ if (/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/.test(value)) {
21
+ return value;
22
+ }
23
+ const cadenceMatch = /^(\d+)(m|h|d)$/.exec(value);
24
+ if (!cadenceMatch)
25
+ return null;
26
+ const interval = Number.parseInt(cadenceMatch[1], 10);
27
+ if (!Number.isFinite(interval) || interval <= 0)
28
+ return null;
29
+ (0, runtime_1.emitNervesEvent)({
30
+ event: "daemon.cadence_parsed",
31
+ component: "daemon",
32
+ message: "parsed cadence shorthand to cron",
33
+ meta: { raw: value, interval, unit: cadenceMatch[2] },
34
+ });
35
+ const unit = cadenceMatch[2];
36
+ if (unit === "m")
37
+ return `*/${interval} * * * *`;
38
+ if (unit === "h")
39
+ return `0 */${interval} * * *`;
40
+ return `0 0 */${interval} * *`;
41
+ }
42
+ /**
43
+ * Parse a cadence shorthand (e.g. "30m", "2h", "1d") into milliseconds.
44
+ * Returns null for invalid input.
45
+ */
46
+ function parseCadenceToMs(raw) {
47
+ if (typeof raw !== "string")
48
+ return null;
49
+ const value = raw.trim();
50
+ if (!value)
51
+ return null;
52
+ const match = /^(\d+)(m|h|d)$/.exec(value);
53
+ if (!match)
54
+ return null;
55
+ const interval = Number.parseInt(match[1], 10);
56
+ if (!Number.isFinite(interval) || interval <= 0)
57
+ return null;
58
+ (0, runtime_1.emitNervesEvent)({
59
+ event: "daemon.cadence_parsed_ms",
60
+ component: "daemon",
61
+ message: "parsed cadence shorthand to milliseconds",
62
+ meta: { raw: value, interval, unit: match[2] },
63
+ });
64
+ const unit = match[2];
65
+ if (unit === "m")
66
+ return interval * 60 * 1000;
67
+ if (unit === "h")
68
+ return interval * 60 * 60 * 1000;
69
+ return interval * 24 * 60 * 60 * 1000;
70
+ }
@@ -283,6 +283,9 @@ function usage() {
283
283
  " ouro chat <agent>",
284
284
  " ouro msg --to <agent> [--session <id>] [--task <ref>] <message>",
285
285
  " ouro poke <agent> --task <task-id>",
286
+ " ouro poke <agent> --habit <name>",
287
+ " ouro habit list [--agent <name>]",
288
+ " ouro habit create [--agent <name>] <name> [--cadence <interval>]",
286
289
  " ouro link <agent> --friend <id> --provider <provider> --external-id <external-id>",
287
290
  " ouro task board [<status>] [--agent <name>]",
288
291
  " ouro task create <title> [--type <type>] [--agent <name>]",
@@ -392,16 +395,55 @@ function parsePokeCommand(args) {
392
395
  if (!agent)
393
396
  throw new Error(`Usage\n${usage()}`);
394
397
  let taskId;
398
+ let habitName;
395
399
  for (let i = 1; i < args.length; i += 1) {
396
400
  if (args[i] === "--task") {
397
401
  taskId = args[i + 1];
398
402
  i += 1;
399
403
  }
404
+ if (args[i] === "--habit") {
405
+ habitName = args[i + 1];
406
+ i += 1;
407
+ }
400
408
  }
409
+ // --habit takes priority over --task
410
+ if (habitName)
411
+ return { kind: "habit.poke", agent, habitName };
401
412
  if (!taskId)
402
413
  throw new Error(`Usage\n${usage()}`);
403
414
  return { kind: "task.poke", agent, taskId };
404
415
  }
416
+ function parseHabitCommand(args) {
417
+ const { agent, rest } = extractAgentFlag(args);
418
+ const sub = rest[0];
419
+ if (sub === "list") {
420
+ return { kind: "habit.list", ...(agent ? { agent } : {}) };
421
+ }
422
+ if (sub === "create") {
423
+ const nameArgs = rest.slice(1);
424
+ let name;
425
+ let cadence;
426
+ const positional = [];
427
+ for (let i = 0; i < nameArgs.length; i++) {
428
+ if (nameArgs[i] === "--cadence" && nameArgs[i + 1]) {
429
+ cadence = nameArgs[++i];
430
+ continue;
431
+ }
432
+ /* v8 ignore start -- defensive: --agent already extracted by extractAgentFlag; guard prevents regression if parsing flow changes @preserve */
433
+ if (nameArgs[i] === "--agent" && nameArgs[i + 1]) {
434
+ i++; // skip --agent value (already extracted)
435
+ continue;
436
+ }
437
+ /* v8 ignore stop */
438
+ positional.push(nameArgs[i]);
439
+ }
440
+ name = positional[0];
441
+ if (!name)
442
+ throw new Error(`Usage\n${usage()}`);
443
+ return { kind: "habit.create", name, ...(agent ? { agent } : {}), ...(cadence ? { cadence } : {}) };
444
+ }
445
+ throw new Error(`Usage\n${usage()}`);
446
+ }
405
447
  function parseLinkCommand(args, kind = "friend.link") {
406
448
  const agent = args[0];
407
449
  if (!agent)
@@ -1008,6 +1050,8 @@ function parseOuroCommand(args) {
1008
1050
  return parseTaskCommand(args.slice(1));
1009
1051
  if (head === "reminder")
1010
1052
  return parseReminderCommand(args.slice(1));
1053
+ if (head === "habit")
1054
+ return parseHabitCommand(args.slice(1));
1011
1055
  if (head === "friend")
1012
1056
  return parseFriendCommand(args.slice(1));
1013
1057
  if (head === "config")
@@ -1825,7 +1869,7 @@ function executeReminderCommand(command, taskMod) {
1825
1869
  try {
1826
1870
  const created = taskMod.createTask({
1827
1871
  title: command.title,
1828
- type: command.cadence ? "habit" : "one-shot",
1872
+ type: command.cadence ? "ongoing" : "one-shot",
1829
1873
  category: command.category ?? "reminder",
1830
1874
  body: command.body,
1831
1875
  scheduledAt: command.scheduledAt,
@@ -2509,6 +2553,61 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
2509
2553
  deps.writeStdout(message);
2510
2554
  return message;
2511
2555
  }
2556
+ // ── habit subcommands (local, no daemon socket needed) ──
2557
+ if (command.kind === "habit.list" || command.kind === "habit.create") {
2558
+ const { parseHabitFile, renderHabitFile } = await Promise.resolve().then(() => __importStar(require("./habit-parser")));
2559
+ /* v8 ignore start -- production default: uses real bundle root @preserve */
2560
+ const agentName = command.agent ?? (0, identity_1.getAgentName)();
2561
+ const bundleRoot = deps.agentBundleRoot ?? path.join((0, identity_1.getAgentBundlesRoot)(), `${agentName}.ouro`);
2562
+ /* v8 ignore stop */
2563
+ const habitsDir = path.join(bundleRoot, "habits");
2564
+ if (command.kind === "habit.list") {
2565
+ let files;
2566
+ try {
2567
+ files = fs.readdirSync(habitsDir).filter((f) => f.endsWith(".md") && f !== "README.md");
2568
+ }
2569
+ catch {
2570
+ const message = "no habits found";
2571
+ deps.writeStdout(message);
2572
+ return message;
2573
+ }
2574
+ if (files.length === 0) {
2575
+ const message = "no habits found";
2576
+ deps.writeStdout(message);
2577
+ return message;
2578
+ }
2579
+ const lines = [];
2580
+ for (const file of files) {
2581
+ const content = fs.readFileSync(path.join(habitsDir, file), "utf-8");
2582
+ const habit = parseHabitFile(content, path.join(habitsDir, file));
2583
+ const lastRunStr = habit.lastRun ?? "never";
2584
+ lines.push(`${habit.name} cadence=${habit.cadence ?? "none"} status=${habit.status} lastRun=${lastRunStr}`);
2585
+ }
2586
+ const message = lines.join("\n");
2587
+ deps.writeStdout(message);
2588
+ return message;
2589
+ }
2590
+ // habit.create
2591
+ const filePath = path.join(habitsDir, `${command.name}.md`);
2592
+ if (fs.existsSync(filePath)) {
2593
+ const message = `error: habit '${command.name}' already exists`;
2594
+ deps.writeStdout(message);
2595
+ return message;
2596
+ }
2597
+ fs.mkdirSync(habitsDir, { recursive: true });
2598
+ const now = new Date().toISOString();
2599
+ const content = renderHabitFile({
2600
+ title: command.name,
2601
+ cadence: command.cadence ?? "null",
2602
+ status: "active",
2603
+ lastRun: now,
2604
+ created: now,
2605
+ }, `Habit: ${command.name}`);
2606
+ fs.writeFileSync(filePath, content, "utf-8");
2607
+ const message = `created: ${filePath}`;
2608
+ deps.writeStdout(message);
2609
+ return message;
2610
+ }
2512
2611
  // ── friend subcommands (local, no daemon socket needed) ──
2513
2612
  if (command.kind === "friend.list" || command.kind === "friend.show" || command.kind === "friend.create" ||
2514
2613
  command.kind === "friend.update" || command.kind === "friend.link" || command.kind === "friend.unlink") {
@@ -2860,7 +2959,7 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
2860
2959
  const agentRoot = (0, identity_1.getAgentRoot)(agentName);
2861
2960
  const { buildInnerStatusOutput } = await Promise.resolve().then(() => __importStar(require("./inner-status")));
2862
2961
  const { sessionPath: getSessionPath } = await Promise.resolve().then(() => __importStar(require("../config")));
2863
- const { parseCadenceMs, DEFAULT_CADENCE_MS } = await Promise.resolve().then(() => __importStar(require("./heartbeat-timer")));
2962
+ const { parseCadenceToMs: parseCadenceMs, DEFAULT_CADENCE_MS } = await Promise.resolve().then(() => __importStar(require("./cadence")));
2864
2963
  const { parseFrontmatter } = await Promise.resolve().then(() => __importStar(require("../../repertoire/tasks/parser")));
2865
2964
  const { listActiveObligations } = await Promise.resolve().then(() => __importStar(require("../../mind/obligations")));
2866
2965
  // Read runtime state
@@ -2889,11 +2988,10 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
2889
2988
  let heartbeat = null;
2890
2989
  try {
2891
2990
  const habitsDir = path.join(agentRoot, "habits");
2892
- const habitFiles = fs.readdirSync(habitsDir);
2893
- const heartbeatFile = habitFiles.find((f) => f.endsWith("-heartbeat.md"));
2991
+ const heartbeatPath = path.join(habitsDir, "heartbeat.md");
2894
2992
  let cadenceMs = DEFAULT_CADENCE_MS;
2895
- if (heartbeatFile) {
2896
- const content = fs.readFileSync(path.join(habitsDir, heartbeatFile), "utf-8");
2993
+ if (fs.existsSync(heartbeatPath)) {
2994
+ const content = fs.readFileSync(heartbeatPath, "utf-8");
2897
2995
  const lines = content.split(/\r?\n/);
2898
2996
  if (lines[0]?.trim() === "---") {
2899
2997
  const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
@@ -47,7 +47,10 @@ const sense_manager_1 = require("./sense-manager");
47
47
  const agent_discovery_1 = require("./agent-discovery");
48
48
  const identity_1 = require("../identity");
49
49
  const runtime_mode_1 = require("./runtime-mode");
50
- const heartbeat_timer_1 = require("./heartbeat-timer");
50
+ const habit_scheduler_1 = require("./habit-scheduler");
51
+ const habit_migration_1 = require("./habit-migration");
52
+ const os_cron_deps_1 = require("./os-cron-deps");
53
+ const os_cron_1 = require("./os-cron");
51
54
  function parseSocketPath(argv) {
52
55
  const socketIndex = argv.indexOf("--socket");
53
56
  if (socketIndex >= 0) {
@@ -116,24 +119,38 @@ const daemon = new daemon_1.OuroDaemon({
116
119
  router,
117
120
  mode,
118
121
  });
119
- const heartbeatTimers = [];
120
- /* v8 ignore start -- heartbeat wiring: lambdas delegate to processManager/fs; tested via HeartbeatTimer unit tests @preserve */
122
+ const habitSchedulers = [];
123
+ /* v8 ignore start -- habit wiring: lambdas delegate to processManager/fs; tested via HabitScheduler unit tests @preserve */
121
124
  void daemon.start().then(() => {
122
125
  const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
126
+ const ouroPath = (0, os_cron_deps_1.resolveOuroBinaryPath)();
127
+ const osCronDeps = (0, os_cron_deps_1.createRealOsCronDeps)();
123
128
  for (const agent of managedAgents) {
124
129
  const bundleRoot = path.join(bundlesRoot, `${agent}.ouro`);
125
- const timer = new heartbeat_timer_1.HeartbeatTimer({
130
+ const habitsDir = path.join(bundleRoot, "habits");
131
+ // Migrate old tasks/habits/ to habits/ at bundle root
132
+ (0, habit_migration_1.migrateHabitsFromTaskSystem)(bundleRoot);
133
+ const osCronManager = new os_cron_1.LaunchdCronManager(osCronDeps);
134
+ const scheduler = new habit_scheduler_1.HabitScheduler({
126
135
  agent,
127
- sendToAgent: (a, msg) => processManager.sendToAgent(a, msg),
136
+ habitsDir,
137
+ osCronManager,
138
+ onHabitFire: (habitName) => {
139
+ processManager.sendToAgent(agent, { type: "habit", habitName });
140
+ },
128
141
  deps: {
129
- readFileSync: (p, enc) => fs.readFileSync(p, enc),
130
- readdirSync: (p) => fs.readdirSync(p).map((e) => (typeof e === "string" ? e : e)),
131
- heartbeatTaskDir: path.join(bundleRoot, "tasks", "habits"),
132
- runtimeStatePath: path.join(bundleRoot, "state", "sessions", "self", "inner", "runtime.json"),
142
+ readdir: (dir) => fs.readdirSync(dir),
143
+ readFile: (p, enc) => fs.readFileSync(p, enc),
144
+ writeFile: (p, c, enc) => fs.writeFileSync(p, c, enc),
145
+ existsSync: (p) => fs.existsSync(p),
146
+ now: () => Date.now(),
147
+ ouroPath,
148
+ watch: (dir, cb) => fs.watch(dir, cb),
133
149
  },
134
150
  });
135
- timer.start();
136
- heartbeatTimers.push(timer);
151
+ scheduler.start();
152
+ scheduler.watchForChanges();
153
+ habitSchedulers.push(scheduler);
137
154
  }
138
155
  }).catch(async () => {
139
156
  /* v8 ignore stop */
@@ -148,12 +165,16 @@ void daemon.start().then(() => {
148
165
  process.exit(1);
149
166
  });
150
167
  process.on("SIGINT", () => {
151
- for (const timer of heartbeatTimers)
152
- timer.stop();
168
+ for (const s of habitSchedulers) {
169
+ s.stopWatching();
170
+ s.stop();
171
+ }
153
172
  void daemon.stop().then(() => process.exit(0));
154
173
  });
155
174
  process.on("SIGTERM", () => {
156
- for (const timer of heartbeatTimers)
157
- timer.stop();
175
+ for (const s of habitSchedulers) {
176
+ s.stopWatching();
177
+ s.stop();
178
+ }
158
179
  void daemon.stop().then(() => process.exit(0));
159
180
  });
@@ -606,6 +606,13 @@ class OuroDaemon {
606
606
  data: receipt,
607
607
  };
608
608
  }
609
+ case "habit.poke": {
610
+ this.processManager.sendToAgent?.(command.agent, { type: "habit", habitName: command.habitName });
611
+ return {
612
+ ok: true,
613
+ message: `poked habit ${command.habitName} for ${command.agent}`,
614
+ };
615
+ }
609
616
  case "mcp.list": {
610
617
  const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)();
611
618
  if (!mcpManager) {
@@ -0,0 +1,181 @@
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.migrateHabitsFromTaskSystem = migrateHabitsFromTaskSystem;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const runtime_1 = require("../../nerves/runtime");
40
+ const habit_parser_1 = require("./habit-parser");
41
+ const parser_1 = require("../../repertoire/tasks/parser");
42
+ /** Fields that belong to the task system and should be stripped from migrated habits. */
43
+ const TASK_ONLY_FIELDS = new Set([
44
+ "type",
45
+ "category",
46
+ "requester",
47
+ "validator",
48
+ "scheduledAt",
49
+ "updated",
50
+ "depends_on",
51
+ "parent_task",
52
+ "artifacts",
53
+ ]);
54
+ /** Regex matching the YYYY-MM-DD-HHMM- timestamp prefix in task filenames. */
55
+ const TIMESTAMP_PREFIX = /^\d{4}-\d{2}-\d{2}-\d{4}-/;
56
+ /** Map old task statuses to habit statuses. */
57
+ function mapStatus(taskStatus) {
58
+ if (taskStatus === "processing")
59
+ return "active";
60
+ if (taskStatus === "paused")
61
+ return "paused";
62
+ if (taskStatus === "done")
63
+ return null; // skip done habits
64
+ return "active"; // default to active for unknown statuses
65
+ }
66
+ /** Strip timestamp prefix from filename to get the slug name. */
67
+ function stripTimestampPrefix(filename) {
68
+ return filename.replace(TIMESTAMP_PREFIX, "");
69
+ }
70
+ /**
71
+ * Migrate habit files from the old `tasks/habits/` location to the new `habits/` bundle root.
72
+ * - Strips timestamp prefix from filenames
73
+ * - Maps task statuses to habit statuses
74
+ * - Strips task-only frontmatter fields
75
+ * - Preserves body text
76
+ * - Skips done habits, README files, and already-migrated habits
77
+ * - No-op if `tasks/habits/` does not exist
78
+ */
79
+ function migrateHabitsFromTaskSystem(bundleRoot) {
80
+ const oldHabitsDir = path.join(bundleRoot, "tasks", "habits");
81
+ if (!fs.existsSync(oldHabitsDir)) {
82
+ return;
83
+ }
84
+ let files;
85
+ try {
86
+ files = fs.readdirSync(oldHabitsDir);
87
+ }
88
+ catch {
89
+ /* v8 ignore next -- race condition: dir removed between existsSync and readdirSync @preserve */
90
+ return;
91
+ }
92
+ const mdFiles = files.filter((f) => f.endsWith(".md") && f !== "README.md");
93
+ if (mdFiles.length === 0)
94
+ return;
95
+ const newHabitsDir = path.join(bundleRoot, "habits");
96
+ fs.mkdirSync(newHabitsDir, { recursive: true });
97
+ let migratedCount = 0;
98
+ for (const file of mdFiles) {
99
+ const slugName = stripTimestampPrefix(file);
100
+ const targetPath = path.join(newHabitsDir, slugName);
101
+ // Skip if already migrated
102
+ if (fs.existsSync(targetPath)) {
103
+ (0, runtime_1.emitNervesEvent)({
104
+ component: "daemon",
105
+ event: "daemon.habit_migration_skip",
106
+ message: "habit already exists at target, skipping",
107
+ meta: { file, targetPath },
108
+ });
109
+ continue;
110
+ }
111
+ const sourcePath = path.join(oldHabitsDir, file);
112
+ let content;
113
+ try {
114
+ content = fs.readFileSync(sourcePath, "utf-8");
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ // Parse frontmatter and body
120
+ const lines = content.split(/\r?\n/);
121
+ if (lines[0]?.trim() !== "---")
122
+ continue;
123
+ const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
124
+ if (closing === -1)
125
+ continue;
126
+ const rawFrontmatter = lines.slice(1, closing).join("\n");
127
+ const body = lines.slice(closing + 1).join("\n").trim();
128
+ const frontmatter = (0, parser_1.parseFrontmatter)(rawFrontmatter);
129
+ // Check status — skip done habits
130
+ const rawStatus = typeof frontmatter.status === "string" ? frontmatter.status : "processing";
131
+ const habitStatus = mapStatus(rawStatus);
132
+ if (habitStatus === null) {
133
+ (0, runtime_1.emitNervesEvent)({
134
+ component: "daemon",
135
+ event: "daemon.habit_migration_skip",
136
+ message: "skipping done habit",
137
+ meta: { file, status: rawStatus },
138
+ });
139
+ continue;
140
+ }
141
+ // Build new frontmatter, stripping task-only fields
142
+ const newFrontmatter = {};
143
+ if (typeof frontmatter.title === "string")
144
+ newFrontmatter.title = frontmatter.title;
145
+ if (typeof frontmatter.cadence === "string")
146
+ newFrontmatter.cadence = frontmatter.cadence;
147
+ newFrontmatter.status = habitStatus;
148
+ newFrontmatter.lastRun = typeof frontmatter.lastRun === "string" && frontmatter.lastRun !== "null"
149
+ ? frontmatter.lastRun
150
+ : "null";
151
+ newFrontmatter.created = typeof frontmatter.created === "string" ? frontmatter.created : "null";
152
+ // Add any other non-task fields from original
153
+ for (const [key, value] of Object.entries(frontmatter)) {
154
+ if (TASK_ONLY_FIELDS.has(key))
155
+ continue;
156
+ if (key in newFrontmatter)
157
+ continue;
158
+ /* v8 ignore next -- dead code: status is caught by `key in newFrontmatter` above since newFrontmatter.status is always set @preserve */
159
+ if (key === "status")
160
+ continue; // already mapped
161
+ newFrontmatter[key] = value;
162
+ }
163
+ const rendered = (0, habit_parser_1.renderHabitFile)(newFrontmatter, body);
164
+ fs.writeFileSync(targetPath, rendered, "utf-8");
165
+ migratedCount++;
166
+ (0, runtime_1.emitNervesEvent)({
167
+ component: "daemon",
168
+ event: "daemon.habit_migrated",
169
+ message: "migrated habit from task system",
170
+ meta: { from: sourcePath, to: targetPath },
171
+ });
172
+ }
173
+ if (migratedCount > 0) {
174
+ (0, runtime_1.emitNervesEvent)({
175
+ component: "daemon",
176
+ event: "daemon.habit_migration_complete",
177
+ message: "habit migration complete",
178
+ meta: { bundleRoot, count: migratedCount },
179
+ });
180
+ }
181
+ }
@@ -0,0 +1,119 @@
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.parseHabitFile = parseHabitFile;
37
+ exports.renderHabitFile = renderHabitFile;
38
+ const path = __importStar(require("path"));
39
+ const parser_1 = require("../../repertoire/tasks/parser");
40
+ const runtime_1 = require("../../nerves/runtime");
41
+ function isHabitStatus(value) {
42
+ return value === "active" || value === "paused";
43
+ }
44
+ function extractFrontmatterAndBody(content) {
45
+ const lines = content.split(/\r?\n/);
46
+ if (lines[0]?.trim() !== "---") {
47
+ return null;
48
+ }
49
+ const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
50
+ if (closing === -1) {
51
+ return null;
52
+ }
53
+ const rawFrontmatter = lines.slice(1, closing).join("\n");
54
+ const body = lines.slice(closing + 1).join("\n").trim();
55
+ return { frontmatter: (0, parser_1.parseFrontmatter)(rawFrontmatter), body };
56
+ }
57
+ function parseHabitFile(content, filePath) {
58
+ (0, runtime_1.emitNervesEvent)({
59
+ event: "daemon.habit_parse",
60
+ component: "daemon",
61
+ message: "parsing habit file",
62
+ meta: { filePath },
63
+ });
64
+ const stem = path.basename(filePath, ".md");
65
+ const parsed = extractFrontmatterAndBody(content);
66
+ if (!parsed) {
67
+ return {
68
+ name: stem,
69
+ title: stem,
70
+ cadence: null,
71
+ status: "active",
72
+ lastRun: null,
73
+ created: null,
74
+ body: content.trim(),
75
+ };
76
+ }
77
+ const { frontmatter, body } = parsed;
78
+ const rawTitle = frontmatter.title;
79
+ const title = typeof rawTitle === "string" && rawTitle.length > 0 ? rawTitle : stem;
80
+ const rawCadence = frontmatter.cadence;
81
+ const cadence = typeof rawCadence === "string" && rawCadence.length > 0 ? rawCadence : null;
82
+ const rawStatus = frontmatter.status;
83
+ const status = typeof rawStatus === "string" && isHabitStatus(rawStatus) ? rawStatus : "active";
84
+ const rawLastRun = frontmatter.lastRun;
85
+ const lastRun = typeof rawLastRun === "string" && rawLastRun.length > 0 ? rawLastRun : null;
86
+ const rawCreated = frontmatter.created;
87
+ const created = typeof rawCreated === "string" && rawCreated.length > 0 ? rawCreated : null;
88
+ return {
89
+ name: stem,
90
+ title,
91
+ cadence,
92
+ status,
93
+ lastRun,
94
+ created,
95
+ body,
96
+ };
97
+ }
98
+ function formatFrontmatterValue(value) {
99
+ if (value === null || value === undefined)
100
+ return "null";
101
+ return String(value);
102
+ }
103
+ function renderHabitFile(frontmatter, body) {
104
+ (0, runtime_1.emitNervesEvent)({
105
+ event: "daemon.habit_render",
106
+ component: "daemon",
107
+ message: "rendering habit file",
108
+ meta: {},
109
+ });
110
+ const lines = ["---"];
111
+ for (const key of Object.keys(frontmatter)) {
112
+ lines.push(`${key}: ${formatFrontmatterValue(frontmatter[key])}`);
113
+ }
114
+ lines.push("---");
115
+ lines.push("");
116
+ lines.push(body.trim());
117
+ lines.push("");
118
+ return lines.join("\n");
119
+ }