@ouro.bot/cli 0.1.0-alpha.156 → 0.1.0-alpha.157

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,16 @@
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.157",
6
+ "changes": [
7
+ "Habit turns as awareness: unified buildHabitTurnMessage replaces contextual-heartbeat. Continuity-first format (checkpoint leads, not elapsed time). Same format for all habits \u2014 no heartbeat special-casing.",
8
+ "First beat experience: new habits get \"your [Title] is alive. this is its first breath\" on first fire.",
9
+ "Fix: reconcile() now fires new/overdue habits immediately (was start()-only). New habits created via write_file fire within seconds.",
10
+ "Rhythm awareness across all channels: rhythmStatusSection() in system prompt shows heartbeat health in every conversation.",
11
+ "Removed contextual-heartbeat.ts entirely. Dead lastSurfaceAt parameter removed."
12
+ ]
13
+ },
4
14
  {
5
15
  "version": "0.1.0-alpha.156",
6
16
  "changes": [
@@ -1033,4 +1043,4 @@
1033
1043
  ]
1034
1044
  }
1035
1045
  ]
1036
- }
1046
+ }
@@ -73,7 +73,24 @@ class HabitScheduler {
73
73
  const jobs = this.buildJobs(habits);
74
74
  this.osCronManager.sync(jobs);
75
75
  this.verifyCronAndCreateFallbacks(jobs);
76
- // Fire overdue habits immediately
76
+ this.fireOverdueHabits(habits);
77
+ }
78
+ reconcile() {
79
+ (0, runtime_1.emitNervesEvent)({
80
+ component: "daemon",
81
+ event: "daemon.habit_scheduler_reconcile",
82
+ message: "habit scheduler reconciling",
83
+ meta: { agent: this.agent },
84
+ });
85
+ // Clear ALL existing timers FIRST to prevent overlap window
86
+ this.clearAllTimerFallbacks();
87
+ const habits = this.scanHabits();
88
+ const jobs = this.buildJobs(habits);
89
+ this.osCronManager.sync(jobs);
90
+ this.verifyCronAndCreateFallbacks(jobs);
91
+ this.fireOverdueHabits(habits);
92
+ }
93
+ fireOverdueHabits(habits) {
77
94
  for (const habit of habits) {
78
95
  if (habit.status !== "active")
79
96
  continue;
@@ -106,20 +123,6 @@ class HabitScheduler {
106
123
  }
107
124
  }
108
125
  }
109
- reconcile() {
110
- (0, runtime_1.emitNervesEvent)({
111
- component: "daemon",
112
- event: "daemon.habit_scheduler_reconcile",
113
- message: "habit scheduler reconciling",
114
- meta: { agent: this.agent },
115
- });
116
- // Clear ALL existing timers FIRST to prevent overlap window
117
- this.clearAllTimerFallbacks();
118
- const habits = this.scanHabits();
119
- const jobs = this.buildJobs(habits);
120
- this.osCronManager.sync(jobs);
121
- this.verifyCronAndCreateFallbacks(jobs);
122
- }
123
126
  stop() {
124
127
  (0, runtime_1.emitNervesEvent)({
125
128
  component: "daemon",
@@ -51,6 +51,7 @@ exports.channelNatureSection = channelNatureSection;
51
51
  exports.groupChatParticipationSection = groupChatParticipationSection;
52
52
  exports.feedbackSignalSection = feedbackSignalSection;
53
53
  exports.mixedTrustGroupSection = mixedTrustGroupSection;
54
+ exports.rhythmStatusSection = rhythmStatusSection;
54
55
  exports.buildSystem = buildSystem;
55
56
  const fs = __importStar(require("fs"));
56
57
  const path = __importStar(require("path"));
@@ -71,6 +72,7 @@ const session_activity_1 = require("../heart/session-activity");
71
72
  const active_work_1 = require("../heart/active-work");
72
73
  const commitments_1 = require("../heart/commitments");
73
74
  const obligation_steering_1 = require("./obligation-steering");
75
+ const daemon_health_1 = require("../heart/daemon/daemon-health");
74
76
  // Lazy-loaded psyche text cache
75
77
  let _psycheCache = null;
76
78
  let _senseStatusLinesCache = null;
@@ -886,6 +888,39 @@ function mixedTrustGroupSection(context) {
886
888
  return "";
887
889
  return "## mixed trust group\nin this group chat, my capabilities depend on who's talking. some people here have full trust, others don't — i adjust what i can do based on who's asking.";
888
890
  }
891
+ function formatElapsedBrief(ms) {
892
+ const minutes = Math.floor(ms / 60000);
893
+ if (minutes < 60)
894
+ return `${minutes}m ago`;
895
+ const hours = Math.floor(minutes / 60);
896
+ return `${hours}h ago`;
897
+ }
898
+ function rhythmStatusSection() {
899
+ try {
900
+ const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
901
+ if (!health)
902
+ return "";
903
+ const habitNames = Object.keys(health.habits);
904
+ if (habitNames.length === 0)
905
+ return "";
906
+ const nowMs = Date.now();
907
+ const parts = [];
908
+ for (const name of habitNames) {
909
+ const h = health.habits[name];
910
+ const lastFired = h.lastFired ? formatElapsedBrief(nowMs - new Date(h.lastFired).getTime()) : "never";
911
+ parts.push(`${name} last fired ${lastFired}`);
912
+ }
913
+ const degradedNote = health.degraded.length > 0
914
+ ? health.degraded.map((d) => `${d.component}: ${d.reason}`).join("; ") + "."
915
+ : "healthy.";
916
+ return `my rhythms: ${parts.join(". ")}. ${degradedNote}`;
917
+ /* v8 ignore start -- defensive: readHealth handles its own errors; this catch is a safety net for truly unexpected failures @preserve */
918
+ }
919
+ catch {
920
+ return "";
921
+ }
922
+ /* v8 ignore stop */
923
+ }
889
924
  async function buildSystem(channel = "cli", options, context) {
890
925
  (0, runtime_1.emitNervesEvent)({
891
926
  event: "mind.step_start",
@@ -906,6 +941,7 @@ async function buildSystem(channel = "cli", options, context) {
906
941
  channel === "inner" ? journalSection((0, identity_1.getAgentRoot)()) : "",
907
942
  loopOrientationSection(channel),
908
943
  runtimeInfoSection(channel),
944
+ rhythmStatusSection(),
909
945
  channelNatureSection((0, channel_1.getChannelCapabilities)(channel)),
910
946
  providerSection(),
911
947
  dateSection(),
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildHabitTurnMessage = buildHabitTurnMessage;
4
+ const runtime_1 = require("../nerves/runtime");
5
+ function formatElapsed(ms) {
6
+ const minutes = Math.floor(ms / 60000);
7
+ if (minutes < 60) {
8
+ return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
9
+ }
10
+ const hours = Math.floor(minutes / 60);
11
+ return `${hours} ${hours === 1 ? "hour" : "hours"}`;
12
+ }
13
+ function buildHabitTurnMessage(options) {
14
+ const { habitName, habitTitle, habitBody, lastRun, checkpoint, alsoDue, staleObligations, parseErrors, degradedComponents, now, } = options;
15
+ const hasBody = habitBody !== undefined && habitBody !== "";
16
+ // First beat: lastRun is null
17
+ if (lastRun === null) {
18
+ // Cold start: no checkpoint, no body — bare awareness
19
+ if (!checkpoint && !hasBody) {
20
+ (0, runtime_1.emitNervesEvent)({
21
+ component: "senses",
22
+ event: "senses.habit_turn_message_built",
23
+ message: "habit turn message built (cold start)",
24
+ meta: { habitName, coldStart: true },
25
+ });
26
+ return "...time passing. anything stirring?";
27
+ }
28
+ if (!hasBody) {
29
+ // First beat with no body: nudge
30
+ const sections = [
31
+ `your ${habitTitle} fired but has no instructions \u2014 add a body to \`habits/${habitName}.md\``,
32
+ ];
33
+ appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents);
34
+ (0, runtime_1.emitNervesEvent)({
35
+ component: "senses",
36
+ event: "senses.habit_turn_message_built",
37
+ message: "habit turn message built (first beat, no body)",
38
+ meta: { habitName, firstBeat: true, hasBody: false },
39
+ });
40
+ return sections.join("\n\n");
41
+ }
42
+ const sections = [
43
+ `your ${habitTitle} is alive. this is its first breath.`,
44
+ habitBody,
45
+ ];
46
+ appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents);
47
+ (0, runtime_1.emitNervesEvent)({
48
+ component: "senses",
49
+ event: "senses.habit_turn_message_built",
50
+ message: "habit turn message built (first beat)",
51
+ meta: { habitName, firstBeat: true },
52
+ });
53
+ return sections.join("\n\n");
54
+ }
55
+ // Normal turn
56
+ const sections = [];
57
+ // 1. Checkpoint
58
+ if (checkpoint) {
59
+ sections.push(`you were thinking about ${checkpoint}.`);
60
+ }
61
+ // 2. Elapsed time
62
+ const nowMs = now().getTime();
63
+ const lastRunMs = new Date(lastRun).getTime();
64
+ const elapsed = nowMs - lastRunMs;
65
+ sections.push(`${formatElapsed(elapsed)} have passed.`);
66
+ // 3. Body or no-body nudge
67
+ if (hasBody) {
68
+ sections.push(habitBody);
69
+ }
70
+ else {
71
+ sections.push(`your ${habitTitle} fired but has no instructions \u2014 add a body to \`habits/${habitName}.md\``);
72
+ }
73
+ // 4-7. Trailing extras
74
+ appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents);
75
+ (0, runtime_1.emitNervesEvent)({
76
+ component: "senses",
77
+ event: "senses.habit_turn_message_built",
78
+ message: "habit turn message built",
79
+ meta: {
80
+ habitName,
81
+ hasCheckpoint: !!checkpoint,
82
+ hasBody: hasBody,
83
+ staleObligationCount: staleObligations.length,
84
+ },
85
+ });
86
+ return sections.join("\n\n");
87
+ }
88
+ function appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents) {
89
+ // 4. Also-due
90
+ if (alsoDue) {
91
+ sections.push(alsoDue);
92
+ }
93
+ // 5. Stale obligations
94
+ if (staleObligations.length > 0) {
95
+ const lines = staleObligations.map((o) => `something for ${o.friendName} has been sitting for ${formatElapsed(o.stalenessMs)}`);
96
+ sections.push(lines.join("\n"));
97
+ }
98
+ // 6. Parse errors
99
+ if (parseErrors.length > 0) {
100
+ const lines = parseErrors.map((e) => `I noticed my habit file \`${e.file}\` has invalid frontmatter \u2014 I should fix it. (${e.error})`);
101
+ sections.push(lines.join("\n"));
102
+ }
103
+ // 7. Degraded state
104
+ if (degradedComponents.length > 0) {
105
+ const reasons = degradedComponents.map((d) => `${d.component}: ${d.reason}`).join("; ");
106
+ sections.push(`[note: my scheduling is degraded: ${reasons}]`);
107
+ }
108
+ }
@@ -67,7 +67,7 @@ const manager_1 = require("../heart/bridges/manager");
67
67
  const session_activity_1 = require("../heart/session-activity");
68
68
  const bluebubbles_1 = require("./bluebubbles");
69
69
  const obligations_2 = require("../heart/obligations");
70
- const contextual_heartbeat_1 = require("./contextual-heartbeat");
70
+ const habit_turn_message_1 = require("./habit-turn-message");
71
71
  const journal_index_1 = require("../mind/journal-index");
72
72
  const habit_parser_1 = require("../heart/daemon/habit-parser");
73
73
  const cadence_1 = require("../heart/daemon/cadence");
@@ -559,106 +559,65 @@ async function runInnerDialogTurn(options) {
559
559
  const habitFilePath = path.join(agentRoot, "habits", `${habitName}.md`);
560
560
  // Read and parse the habit file
561
561
  let habitBody;
562
+ let habitTitle = habitName;
562
563
  let habitLastRun = null;
563
564
  try {
564
565
  const habitContent = fs.readFileSync(habitFilePath, "utf-8");
565
566
  const parsed = (0, habit_parser_1.parseHabitFile)(habitContent, habitFilePath);
566
- habitBody = parsed.body;
567
+ habitBody = parsed.body || undefined;
568
+ habitTitle = parsed.title || habitName;
567
569
  habitLastRun = parsed.lastRun;
568
570
  }
569
571
  catch {
570
572
  // Habit file missing or unreadable
571
573
  }
572
- if (habitName === "heartbeat") {
573
- // Heartbeat habit: use contextual heartbeat builder with habit body
574
- const journalDir = path.join(agentRoot, "journal");
575
- const runtimeStatePath = innerDialogRuntimeStatePath(sessionFilePath);
576
- let lastCompletedAt;
577
- try {
578
- const raw = fs.readFileSync(runtimeStatePath, "utf-8");
579
- const runtimeState = JSON.parse(raw);
580
- lastCompletedAt = typeof runtimeState.lastCompletedAt === "string" ? runtimeState.lastCompletedAt : undefined;
581
- }
582
- catch {
583
- // no runtime state — will get cold start fallback
584
- }
574
+ // If the habit file couldn't be read at all (no body, no title parsed), error message
575
+ if (habitBody === undefined && habitTitle === habitName) {
576
+ userContent = `habit "${habitName}" could not be read (file not found or unreadable). check habits/${habitName}.md exists.`;
577
+ }
578
+ else {
579
+ // Unified path: gather context for ALL habits (heartbeat included)
585
580
  const obligations = (0, obligations_1.listActiveObligations)(agentName);
586
581
  const nowMs = now().getTime();
587
- const pendingObligations = obligations.map((o) => ({
588
- id: o.id,
589
- content: o.delegatedContent,
582
+ const staleObligations = obligations.map((o) => ({
590
583
  friendName: o.origin.friendId,
591
- timestamp: o.createdAt,
592
- staleness: nowMs - o.createdAt,
584
+ content: o.delegatedContent,
585
+ stalenessMs: nowMs - o.createdAt,
593
586
  }));
594
- const heartbeatContent = (0, contextual_heartbeat_1.buildContextualHeartbeat)({
595
- journalDir,
596
- lastCompletedAt,
597
- pendingObligations,
598
- lastSurfaceAt: undefined,
599
- checkpoint: state.checkpoint,
600
- now,
601
- readJournalDir: () => (0, prompt_1.readJournalFiles)(journalDir),
587
+ const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
588
+ // Degraded state (best-effort: never crash)
589
+ let degradedComponents = [];
590
+ try {
591
+ const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
592
+ if (health && health.degraded.length > 0) {
593
+ degradedComponents = health.degraded.map((d) => ({ component: d.component, reason: d.reason }));
594
+ }
595
+ }
596
+ catch {
597
+ // Best-effort: missing file or parse error -> empty array, no crash
598
+ }
599
+ userContent = (0, habit_turn_message_1.buildHabitTurnMessage)({
600
+ habitName,
601
+ habitTitle,
602
602
  habitBody,
603
+ lastRun: habitLastRun,
604
+ checkpoint: displayCheckpoint(state.checkpoint),
605
+ alsoDue: alsoDue || undefined,
606
+ staleObligations,
607
+ parseErrors: options?.parseErrors ?? [],
608
+ degradedComponents,
609
+ now,
603
610
  });
604
- // Build also-due line
605
- const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
606
- userContent = alsoDue ? `${heartbeatContent}\n\n${alsoDue}` : heartbeatContent;
607
- // Parse error nudge
608
- const parseErrorNudge = buildParseErrorNudge(options?.parseErrors ?? []);
609
- if (parseErrorNudge)
610
- userContent = `${userContent}\n\n${parseErrorNudge}`;
611
611
  // Piggyback journal embedding indexing (best-effort, fire-and-forget)
612
+ const journalDir = path.join(agentRoot, "journal");
612
613
  /* v8 ignore start -- journal indexing piggyback: embedding provider may not be available; tested via journal-index unit tests @preserve */
613
614
  void (0, journal_index_1.indexJournalFiles)(journalDir, path.join(journalDir, ".index.json"), {
614
615
  embed: async () => [],
615
616
  }).catch(() => {
616
- // swallowed: indexing failure must never block heartbeat
617
+ // swallowed: indexing failure must never block habit turn
617
618
  });
618
619
  /* v8 ignore stop */
619
620
  }
620
- else if (habitBody !== undefined) {
621
- // Non-heartbeat habit: basic context + body
622
- const sections = [];
623
- // Elapsed time since last run
624
- if (habitLastRun) {
625
- const lastRunMs = new Date(habitLastRun).getTime();
626
- const elapsed = now().getTime() - lastRunMs;
627
- const minutes = Math.floor(elapsed / 60000);
628
- if (minutes < 60) {
629
- sections.push(`it's been ${minutes} ${minutes === 1 ? "minute" : "minutes"} since ${habitName} last ran.`);
630
- }
631
- else {
632
- const hours = Math.floor(minutes / 60);
633
- sections.push(`it's been ${hours} ${hours === 1 ? "hour" : "hours"} since ${habitName} last ran.`);
634
- }
635
- }
636
- sections.push(`## habit: ${habitName}\n${habitBody}`);
637
- // Also due line
638
- const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
639
- if (alsoDue)
640
- sections.push(alsoDue);
641
- // Parse error nudge
642
- const parseErrorNudge = buildParseErrorNudge(options?.parseErrors ?? []);
643
- if (parseErrorNudge)
644
- sections.push(parseErrorNudge);
645
- userContent = sections.join("\n\n");
646
- }
647
- else {
648
- // Habit file not found
649
- userContent = `habit "${habitName}" could not be read (file not found or unreadable). check habits/${habitName}.md exists.`;
650
- }
651
- // Degraded state nudge (best-effort: never crash on health file read failure)
652
- try {
653
- const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
654
- if (health && health.degraded.length > 0) {
655
- const reasons = health.degraded.map((d) => `${d.component}: ${d.reason}`).join("; ");
656
- userContent = `${userContent}\n\n[note: my scheduling is degraded: ${reasons}]`;
657
- }
658
- }
659
- catch {
660
- // Best-effort: missing file or parse error -> no nudge, no crash
661
- }
662
621
  }
663
622
  else {
664
623
  userContent = buildInstinctUserMessage(instincts, reason, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.156",
3
+ "version": "0.1.0-alpha.157",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",
@@ -1,106 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.buildContextualHeartbeat = buildContextualHeartbeat;
4
- const runtime_1 = require("../nerves/runtime");
5
- const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
6
- const MAX_JOURNAL_FILES = 10;
7
- function formatElapsed(ms) {
8
- const minutes = Math.floor(ms / 60000);
9
- if (minutes < 60) {
10
- return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
11
- }
12
- const hours = Math.floor(minutes / 60);
13
- return `${hours} ${hours === 1 ? "hour" : "hours"}`;
14
- }
15
- function formatRelativeTime(nowMs, mtimeMs) {
16
- const diffMs = nowMs - mtimeMs;
17
- const minutes = Math.floor(diffMs / 60000);
18
- if (minutes < 1)
19
- return "just now";
20
- if (minutes < 60)
21
- return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
22
- const hours = Math.floor(minutes / 60);
23
- if (hours < 24)
24
- return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
25
- const days = Math.floor(hours / 24);
26
- return `${days} ${days === 1 ? "day" : "days"} ago`;
27
- }
28
- function buildContextualHeartbeat(options) {
29
- const { lastCompletedAt, pendingObligations, lastSurfaceAt, checkpoint, now, readJournalDir, habitBody, } = options;
30
- const nowDate = now();
31
- const nowMs = nowDate.getTime();
32
- const journalFiles = readJournalDir();
33
- // Cold start: no journal files and no runtime state
34
- if (journalFiles.length === 0 && !lastCompletedAt) {
35
- const lines = ["...time passing. anything stirring?"];
36
- if (checkpoint) {
37
- lines.push(`\nlast i remember: ${checkpoint}`);
38
- }
39
- (0, runtime_1.emitNervesEvent)({
40
- component: "senses",
41
- event: "senses.contextual_heartbeat_built",
42
- message: "contextual heartbeat built (cold start)",
43
- meta: { coldStart: true },
44
- });
45
- return lines.join("\n");
46
- }
47
- const sections = [];
48
- // Elapsed time since last turn
49
- if (lastCompletedAt) {
50
- const lastMs = new Date(lastCompletedAt).getTime();
51
- const elapsed = nowMs - lastMs;
52
- sections.push(`it's been ${formatElapsed(elapsed)} since your last turn.`);
53
- }
54
- // Habit body (instructions from habit file)
55
- if (habitBody) {
56
- sections.push(habitBody);
57
- }
58
- // Pending attention count
59
- if (pendingObligations.length > 0) {
60
- const count = pendingObligations.length;
61
- sections.push(`you're holding ${count} ${count === 1 ? "thought" : "thoughts"}.`);
62
- }
63
- // Journal index
64
- if (journalFiles.length > 0) {
65
- const sorted = [...journalFiles].sort((a, b) => b.mtime - a.mtime).slice(0, MAX_JOURNAL_FILES);
66
- const indexLines = ["## journal"];
67
- for (const file of sorted) {
68
- const ago = formatRelativeTime(nowMs, file.mtime);
69
- const previewClause = file.preview ? ` — ${file.preview}` : "";
70
- indexLines.push(`- ${file.name} (${ago})${previewClause}`);
71
- }
72
- sections.push(indexLines.join("\n"));
73
- }
74
- // Journal entries since last surface
75
- if (lastSurfaceAt && journalFiles.length > 0) {
76
- const surfaceMs = new Date(lastSurfaceAt).getTime();
77
- const entriesSinceSurface = journalFiles.filter((f) => f.mtime > surfaceMs).length;
78
- if (entriesSinceSurface > 0) {
79
- const surfaceElapsed = formatElapsed(nowMs - surfaceMs);
80
- sections.push(`${entriesSinceSurface} journal ${entriesSinceSurface === 1 ? "entry" : "entries"} since you last surfaced, ${surfaceElapsed} ago.`);
81
- }
82
- }
83
- // Stale obligation alerts
84
- const staleObligations = pendingObligations.filter((o) => o.staleness >= STALE_THRESHOLD_MS);
85
- if (staleObligations.length > 0) {
86
- for (const obligation of staleObligations) {
87
- sections.push(`this has been sitting for ${formatElapsed(obligation.staleness)}: ${obligation.content}`);
88
- }
89
- }
90
- // Checkpoint
91
- if (checkpoint) {
92
- sections.push(`last i remember: ${checkpoint}`);
93
- }
94
- (0, runtime_1.emitNervesEvent)({
95
- component: "senses",
96
- event: "senses.contextual_heartbeat_built",
97
- message: "contextual heartbeat built",
98
- meta: {
99
- hasJournal: journalFiles.length > 0,
100
- hasLastCompleted: !!lastCompletedAt,
101
- obligationCount: pendingObligations.length,
102
- staleCount: staleObligations.length,
103
- },
104
- });
105
- return sections.join("\n\n");
106
- }