forge-openclaw-plugin 0.2.13 → 0.2.18

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 (42) hide show
  1. package/README.md +8 -5
  2. package/dist/assets/{board-C_m78kvK.js → board-2KevHCI0.js} +2 -2
  3. package/dist/assets/{board-C_m78kvK.js.map → board-2KevHCI0.js.map} +1 -1
  4. package/dist/assets/index-CDYW4WDH.js +36 -0
  5. package/dist/assets/index-CDYW4WDH.js.map +1 -0
  6. package/dist/assets/index-yroQr6YZ.css +1 -0
  7. package/dist/assets/{motion-CpZvZumD.js → motion-q19HPmWs.js} +2 -2
  8. package/dist/assets/{motion-CpZvZumD.js.map → motion-q19HPmWs.js.map} +1 -1
  9. package/dist/assets/{table-DtyXTw03.js → table-BDMHBY4a.js} +2 -2
  10. package/dist/assets/{table-DtyXTw03.js.map → table-BDMHBY4a.js.map} +1 -1
  11. package/dist/assets/{ui-BXbpiKyS.js → ui-CQ_AsFs8.js} +2 -2
  12. package/dist/assets/{ui-BXbpiKyS.js.map → ui-CQ_AsFs8.js.map} +1 -1
  13. package/dist/assets/{vendor-QBH6qVEe.js → vendor-5HifrnRK.js} +90 -75
  14. package/dist/assets/{vendor-QBH6qVEe.js.map → vendor-5HifrnRK.js.map} +1 -1
  15. package/dist/assets/{viz-w-IMeueL.js → viz-CQzkRnTu.js} +2 -2
  16. package/dist/assets/{viz-w-IMeueL.js.map → viz-CQzkRnTu.js.map} +1 -1
  17. package/dist/index.html +8 -8
  18. package/dist/openclaw/api-client.d.ts +1 -0
  19. package/dist/openclaw/local-runtime.js +243 -15
  20. package/dist/openclaw/plugin-entry-shared.js +45 -4
  21. package/dist/openclaw/tools.js +15 -0
  22. package/dist/server/app.js +129 -11
  23. package/dist/server/openapi.js +181 -4
  24. package/dist/server/repositories/habits.js +358 -0
  25. package/dist/server/repositories/rewards.js +62 -0
  26. package/dist/server/services/context.js +16 -6
  27. package/dist/server/services/dashboard.js +6 -3
  28. package/dist/server/services/entity-crud.js +23 -1
  29. package/dist/server/services/gamification.js +66 -18
  30. package/dist/server/services/insights.js +2 -1
  31. package/dist/server/services/reviews.js +2 -1
  32. package/dist/server/types.js +140 -1
  33. package/openclaw.plugin.json +1 -1
  34. package/package.json +1 -1
  35. package/server/migrations/003_habits.sql +30 -0
  36. package/server/migrations/004_habit_links.sql +8 -0
  37. package/server/migrations/005_habit_psyche_links.sql +24 -0
  38. package/skills/forge-openclaw/SKILL.md +16 -2
  39. package/skills/forge-openclaw/cron_jobs.md +395 -0
  40. package/dist/assets/index-BWtLtXwb.js +0 -36
  41. package/dist/assets/index-BWtLtXwb.js.map +0 -1
  42. package/dist/assets/index-Dp5GXY_z.css +0 -1
@@ -0,0 +1,358 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDatabase, runInTransaction } from "../db.js";
3
+ import { HttpError } from "../errors.js";
4
+ import { getGoalById } from "./goals.js";
5
+ import { getProjectById } from "./projects.js";
6
+ import { getBehaviorById, getBehaviorPatternById, getBeliefEntryById, getModeProfileById, getPsycheValueById, getTriggerReportById } from "./psyche.js";
7
+ import { getTaskById } from "./tasks.js";
8
+ import { recordActivityEvent } from "./activity-events.js";
9
+ import { recordHabitCheckInReward } from "./rewards.js";
10
+ import { createHabitCheckInSchema, createHabitSchema, habitCheckInSchema, habitSchema, updateHabitSchema } from "../types.js";
11
+ function todayKey(now = new Date()) {
12
+ return now.toISOString().slice(0, 10);
13
+ }
14
+ function parseWeekDays(raw) {
15
+ const parsed = JSON.parse(raw);
16
+ return Array.isArray(parsed) ? parsed.filter((value) => Number.isInteger(value) && value >= 0 && value <= 6) : [];
17
+ }
18
+ function parseIdList(raw) {
19
+ const parsed = JSON.parse(raw);
20
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
21
+ }
22
+ function uniqueIds(values) {
23
+ return [...new Set(values.filter((value) => typeof value === "string" && value.trim().length > 0))];
24
+ }
25
+ function normalizeLinkedBehaviorIds(input) {
26
+ const fromArray = Array.isArray(input.linkedBehaviorIds) ? input.linkedBehaviorIds : [];
27
+ return uniqueIds([...fromArray, input.linkedBehaviorId ?? null]);
28
+ }
29
+ function validateExistingIds(ids, getById, code, label) {
30
+ for (const id of ids) {
31
+ if (!getById(id)) {
32
+ throw new HttpError(404, code, `${label} ${id} does not exist`);
33
+ }
34
+ }
35
+ }
36
+ function mapCheckIn(row) {
37
+ return habitCheckInSchema.parse({
38
+ id: row.id,
39
+ habitId: row.habit_id,
40
+ dateKey: row.date_key,
41
+ status: row.status,
42
+ note: row.note,
43
+ deltaXp: row.delta_xp,
44
+ createdAt: row.created_at,
45
+ updatedAt: row.updated_at
46
+ });
47
+ }
48
+ function listCheckInsForHabit(habitId, limit = 14) {
49
+ const rows = getDatabase()
50
+ .prepare(`SELECT id, habit_id, date_key, status, note, delta_xp, created_at, updated_at
51
+ FROM habit_check_ins
52
+ WHERE habit_id = ?
53
+ ORDER BY date_key DESC, created_at DESC
54
+ LIMIT ?`)
55
+ .all(habitId, limit);
56
+ return rows.map(mapCheckIn);
57
+ }
58
+ function isAligned(habit, checkIn) {
59
+ return (habit.polarity === "positive" && checkIn.status === "done") || (habit.polarity === "negative" && checkIn.status === "missed");
60
+ }
61
+ function calculateCompletionRate(habit, checkIns) {
62
+ if (checkIns.length === 0) {
63
+ return 0;
64
+ }
65
+ const aligned = checkIns.filter((checkIn) => isAligned(habit, checkIn)).length;
66
+ return Math.round((aligned / checkIns.length) * 100);
67
+ }
68
+ function calculateStreak(habit, checkIns) {
69
+ let streak = 0;
70
+ for (const checkIn of checkIns) {
71
+ if (!isAligned(habit, checkIn)) {
72
+ break;
73
+ }
74
+ streak += 1;
75
+ }
76
+ return streak;
77
+ }
78
+ function isHabitDueToday(habit, latestCheckIn, now = new Date()) {
79
+ if (habit.status !== "active") {
80
+ return false;
81
+ }
82
+ const key = todayKey(now);
83
+ if (latestCheckIn?.dateKey === key) {
84
+ return false;
85
+ }
86
+ if (habit.frequency === "daily") {
87
+ return true;
88
+ }
89
+ return habit.weekDays.includes(now.getUTCDay());
90
+ }
91
+ function mapHabit(row, checkIns = listCheckInsForHabit(row.id)) {
92
+ const latestCheckIn = checkIns[0] ?? null;
93
+ const linkedBehaviorIds = normalizeLinkedBehaviorIds({
94
+ linkedBehaviorIds: parseIdList(row.linked_behavior_ids_json),
95
+ linkedBehaviorId: row.linked_behavior_id
96
+ });
97
+ const linkedBehaviors = linkedBehaviorIds
98
+ .map((behaviorId) => getBehaviorById(behaviorId))
99
+ .filter((behavior) => behavior !== undefined);
100
+ const draft = {
101
+ id: row.id,
102
+ title: row.title,
103
+ description: row.description,
104
+ status: row.status,
105
+ polarity: row.polarity,
106
+ frequency: row.frequency,
107
+ targetCount: row.target_count,
108
+ weekDays: parseWeekDays(row.week_days_json),
109
+ linkedGoalIds: parseIdList(row.linked_goal_ids_json),
110
+ linkedProjectIds: parseIdList(row.linked_project_ids_json),
111
+ linkedTaskIds: parseIdList(row.linked_task_ids_json),
112
+ linkedValueIds: parseIdList(row.linked_value_ids_json),
113
+ linkedPatternIds: parseIdList(row.linked_pattern_ids_json),
114
+ linkedBehaviorIds,
115
+ linkedBeliefIds: parseIdList(row.linked_belief_ids_json),
116
+ linkedModeIds: parseIdList(row.linked_mode_ids_json),
117
+ linkedReportIds: parseIdList(row.linked_report_ids_json),
118
+ linkedBehaviorId: linkedBehaviorIds[0] ?? null,
119
+ linkedBehaviorTitle: linkedBehaviors[0]?.title ?? null,
120
+ linkedBehaviorTitles: linkedBehaviors.map((behavior) => behavior.title),
121
+ rewardXp: row.reward_xp,
122
+ penaltyXp: row.penalty_xp,
123
+ createdAt: row.created_at,
124
+ updatedAt: row.updated_at,
125
+ lastCheckInAt: latestCheckIn?.createdAt ?? null,
126
+ lastCheckInStatus: latestCheckIn?.status ?? null,
127
+ streakCount: calculateStreak({ polarity: row.polarity }, checkIns),
128
+ completionRate: calculateCompletionRate({ polarity: row.polarity }, checkIns),
129
+ dueToday: false,
130
+ checkIns
131
+ };
132
+ draft.dueToday = isHabitDueToday({ status: draft.status, frequency: draft.frequency, weekDays: draft.weekDays }, latestCheckIn);
133
+ return habitSchema.parse(draft);
134
+ }
135
+ function getHabitRow(habitId) {
136
+ return getDatabase()
137
+ .prepare(`SELECT
138
+ id, title, description, status, polarity, frequency, target_count, week_days_json,
139
+ linked_goal_ids_json, linked_project_ids_json, linked_task_ids_json,
140
+ linked_value_ids_json, linked_pattern_ids_json, linked_behavior_ids_json,
141
+ linked_belief_ids_json, linked_mode_ids_json, linked_report_ids_json,
142
+ linked_behavior_id, reward_xp, penalty_xp, created_at, updated_at
143
+ FROM habits
144
+ WHERE id = ?`)
145
+ .get(habitId);
146
+ }
147
+ export function listHabits(filters = {}) {
148
+ const parsed = filters;
149
+ const whereClauses = [];
150
+ const params = [];
151
+ if (parsed.status) {
152
+ whereClauses.push("status = ?");
153
+ params.push(parsed.status);
154
+ }
155
+ if (parsed.polarity) {
156
+ whereClauses.push("polarity = ?");
157
+ params.push(parsed.polarity);
158
+ }
159
+ const limitSql = parsed.limit ? "LIMIT ?" : "";
160
+ if (parsed.limit) {
161
+ params.push(parsed.limit);
162
+ }
163
+ const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
164
+ const rows = getDatabase()
165
+ .prepare(`SELECT
166
+ id, title, description, status, polarity, frequency, target_count, week_days_json,
167
+ linked_goal_ids_json, linked_project_ids_json, linked_task_ids_json,
168
+ linked_value_ids_json, linked_pattern_ids_json, linked_behavior_ids_json,
169
+ linked_belief_ids_json, linked_mode_ids_json, linked_report_ids_json,
170
+ linked_behavior_id, reward_xp, penalty_xp, created_at, updated_at
171
+ FROM habits
172
+ ${whereSql}
173
+ ORDER BY
174
+ CASE status WHEN 'active' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
175
+ updated_at DESC
176
+ ${limitSql}`)
177
+ .all(...params);
178
+ const habits = rows.map((row) => mapHabit(row));
179
+ return parsed.dueToday ? habits.filter((habit) => habit.dueToday) : habits;
180
+ }
181
+ export function getHabitById(habitId) {
182
+ const row = getHabitRow(habitId);
183
+ return row ? mapHabit(row) : undefined;
184
+ }
185
+ export function createHabit(input, activity) {
186
+ const parsed = createHabitSchema.parse(input);
187
+ const linkedBehaviorIds = normalizeLinkedBehaviorIds(parsed);
188
+ validateExistingIds(parsed.linkedGoalIds, getGoalById, "goal_not_found", "Goal");
189
+ validateExistingIds(parsed.linkedProjectIds, getProjectById, "project_not_found", "Project");
190
+ validateExistingIds(parsed.linkedTaskIds, getTaskById, "task_not_found", "Task");
191
+ validateExistingIds(parsed.linkedValueIds, getPsycheValueById, "value_not_found", "Value");
192
+ validateExistingIds(parsed.linkedPatternIds, getBehaviorPatternById, "pattern_not_found", "Pattern");
193
+ validateExistingIds(linkedBehaviorIds, getBehaviorById, "behavior_not_found", "Behavior");
194
+ validateExistingIds(parsed.linkedBeliefIds, getBeliefEntryById, "belief_not_found", "Belief");
195
+ validateExistingIds(parsed.linkedModeIds, getModeProfileById, "mode_not_found", "Mode");
196
+ validateExistingIds(parsed.linkedReportIds, getTriggerReportById, "report_not_found", "Report");
197
+ return runInTransaction(() => {
198
+ const now = new Date().toISOString();
199
+ const id = `habit_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
200
+ getDatabase()
201
+ .prepare(`INSERT INTO habits (
202
+ id, title, description, status, polarity, frequency, target_count, week_days_json,
203
+ linked_goal_ids_json, linked_project_ids_json, linked_task_ids_json,
204
+ linked_value_ids_json, linked_pattern_ids_json, linked_behavior_ids_json,
205
+ linked_belief_ids_json, linked_mode_ids_json, linked_report_ids_json,
206
+ linked_behavior_id, reward_xp, penalty_xp, created_at, updated_at
207
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
208
+ .run(id, parsed.title, parsed.description, parsed.status, parsed.polarity, parsed.frequency, parsed.targetCount, JSON.stringify(parsed.weekDays), JSON.stringify(parsed.linkedGoalIds), JSON.stringify(parsed.linkedProjectIds), JSON.stringify(parsed.linkedTaskIds), JSON.stringify(parsed.linkedValueIds), JSON.stringify(parsed.linkedPatternIds), JSON.stringify(linkedBehaviorIds), JSON.stringify(parsed.linkedBeliefIds), JSON.stringify(parsed.linkedModeIds), JSON.stringify(parsed.linkedReportIds), linkedBehaviorIds[0] ?? null, parsed.rewardXp, parsed.penaltyXp, now, now);
209
+ const habit = getHabitById(id);
210
+ if (activity) {
211
+ recordActivityEvent({
212
+ entityType: "habit",
213
+ entityId: habit.id,
214
+ eventType: "habit_created",
215
+ title: `Habit created: ${habit.title}`,
216
+ description: `${habit.frequency === "daily" ? "Daily" : "Weekly"} ${habit.polarity} habit added to Forge.`,
217
+ actor: activity.actor ?? null,
218
+ source: activity.source,
219
+ metadata: {
220
+ polarity: habit.polarity,
221
+ frequency: habit.frequency,
222
+ targetCount: habit.targetCount
223
+ }
224
+ });
225
+ }
226
+ return habit;
227
+ });
228
+ }
229
+ export function updateHabit(habitId, input, activity) {
230
+ const current = getHabitById(habitId);
231
+ if (!current) {
232
+ return undefined;
233
+ }
234
+ const parsed = updateHabitSchema.parse(input);
235
+ const nextLinkedBehaviorIds = parsed.linkedBehaviorIds !== undefined || parsed.linkedBehaviorId !== undefined
236
+ ? normalizeLinkedBehaviorIds({
237
+ linkedBehaviorIds: parsed.linkedBehaviorIds ?? current.linkedBehaviorIds,
238
+ linkedBehaviorId: parsed.linkedBehaviorId === undefined
239
+ ? current.linkedBehaviorId
240
+ : parsed.linkedBehaviorId
241
+ })
242
+ : current.linkedBehaviorIds;
243
+ validateExistingIds(parsed.linkedGoalIds ?? current.linkedGoalIds, getGoalById, "goal_not_found", "Goal");
244
+ validateExistingIds(parsed.linkedProjectIds ?? current.linkedProjectIds, getProjectById, "project_not_found", "Project");
245
+ validateExistingIds(parsed.linkedTaskIds ?? current.linkedTaskIds, getTaskById, "task_not_found", "Task");
246
+ validateExistingIds(parsed.linkedValueIds ?? current.linkedValueIds, getPsycheValueById, "value_not_found", "Value");
247
+ validateExistingIds(parsed.linkedPatternIds ?? current.linkedPatternIds, getBehaviorPatternById, "pattern_not_found", "Pattern");
248
+ validateExistingIds(nextLinkedBehaviorIds, getBehaviorById, "behavior_not_found", "Behavior");
249
+ validateExistingIds(parsed.linkedBeliefIds ?? current.linkedBeliefIds, getBeliefEntryById, "belief_not_found", "Belief");
250
+ validateExistingIds(parsed.linkedModeIds ?? current.linkedModeIds, getModeProfileById, "mode_not_found", "Mode");
251
+ validateExistingIds(parsed.linkedReportIds ?? current.linkedReportIds, getTriggerReportById, "report_not_found", "Report");
252
+ return runInTransaction(() => {
253
+ const updatedAt = new Date().toISOString();
254
+ getDatabase()
255
+ .prepare(`UPDATE habits
256
+ SET title = ?, description = ?, status = ?, polarity = ?, frequency = ?, target_count = ?,
257
+ week_days_json = ?, linked_goal_ids_json = ?, linked_project_ids_json = ?, linked_task_ids_json = ?,
258
+ linked_value_ids_json = ?, linked_pattern_ids_json = ?, linked_behavior_ids_json = ?,
259
+ linked_belief_ids_json = ?, linked_mode_ids_json = ?, linked_report_ids_json = ?,
260
+ linked_behavior_id = ?, reward_xp = ?, penalty_xp = ?, updated_at = ?
261
+ WHERE id = ?`)
262
+ .run(parsed.title ?? current.title, parsed.description ?? current.description, parsed.status ?? current.status, parsed.polarity ?? current.polarity, parsed.frequency ?? current.frequency, parsed.targetCount ?? current.targetCount, JSON.stringify(parsed.weekDays ?? current.weekDays), JSON.stringify(parsed.linkedGoalIds ?? current.linkedGoalIds), JSON.stringify(parsed.linkedProjectIds ?? current.linkedProjectIds), JSON.stringify(parsed.linkedTaskIds ?? current.linkedTaskIds), JSON.stringify(parsed.linkedValueIds ?? current.linkedValueIds), JSON.stringify(parsed.linkedPatternIds ?? current.linkedPatternIds), JSON.stringify(nextLinkedBehaviorIds), JSON.stringify(parsed.linkedBeliefIds ?? current.linkedBeliefIds), JSON.stringify(parsed.linkedModeIds ?? current.linkedModeIds), JSON.stringify(parsed.linkedReportIds ?? current.linkedReportIds), nextLinkedBehaviorIds[0] ?? null, parsed.rewardXp ?? current.rewardXp, parsed.penaltyXp ?? current.penaltyXp, updatedAt, habitId);
263
+ const habit = getHabitById(habitId);
264
+ if (activity) {
265
+ recordActivityEvent({
266
+ entityType: "habit",
267
+ entityId: habit.id,
268
+ eventType: "habit_updated",
269
+ title: `Habit updated: ${habit.title}`,
270
+ description: "Habit settings and recurrence were updated.",
271
+ actor: activity.actor ?? null,
272
+ source: activity.source,
273
+ metadata: {
274
+ polarity: habit.polarity,
275
+ frequency: habit.frequency,
276
+ targetCount: habit.targetCount
277
+ }
278
+ });
279
+ }
280
+ return habit;
281
+ });
282
+ }
283
+ export function deleteHabit(habitId, activity) {
284
+ const current = getHabitById(habitId);
285
+ if (!current) {
286
+ return undefined;
287
+ }
288
+ return runInTransaction(() => {
289
+ getDatabase().prepare(`DELETE FROM habits WHERE id = ?`).run(habitId);
290
+ if (activity) {
291
+ recordActivityEvent({
292
+ entityType: "habit",
293
+ entityId: current.id,
294
+ eventType: "habit_deleted",
295
+ title: `Habit deleted: ${current.title}`,
296
+ description: "Habit removed from Forge.",
297
+ actor: activity.actor ?? null,
298
+ source: activity.source,
299
+ metadata: {
300
+ polarity: current.polarity,
301
+ frequency: current.frequency
302
+ }
303
+ });
304
+ }
305
+ return current;
306
+ });
307
+ }
308
+ export function createHabitCheckIn(habitId, input, activity) {
309
+ const habit = getHabitById(habitId);
310
+ if (!habit) {
311
+ return undefined;
312
+ }
313
+ const parsed = createHabitCheckInSchema.parse(input);
314
+ return runInTransaction(() => {
315
+ const existing = getDatabase()
316
+ .prepare(`SELECT id, habit_id, date_key, status, note, delta_xp, created_at, updated_at
317
+ FROM habit_check_ins
318
+ WHERE habit_id = ? AND date_key = ?`)
319
+ .get(habitId, parsed.dateKey);
320
+ const reward = recordHabitCheckInReward(habit, parsed.status, parsed.dateKey, activity ?? { source: "ui", actor: null });
321
+ const now = new Date().toISOString();
322
+ if (existing) {
323
+ getDatabase()
324
+ .prepare(`UPDATE habit_check_ins
325
+ SET status = ?, note = ?, delta_xp = ?, updated_at = ?
326
+ WHERE id = ?`)
327
+ .run(parsed.status, parsed.note, reward.deltaXp, now, existing.id);
328
+ }
329
+ else {
330
+ getDatabase()
331
+ .prepare(`INSERT INTO habit_check_ins (id, habit_id, date_key, status, note, delta_xp, created_at, updated_at)
332
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
333
+ .run(`hci_${randomUUID().replaceAll("-", "").slice(0, 10)}`, habitId, parsed.dateKey, parsed.status, parsed.note, reward.deltaXp, now, now);
334
+ }
335
+ recordActivityEvent({
336
+ entityType: "habit",
337
+ entityId: habit.id,
338
+ eventType: parsed.status === "done" ? "habit_done" : "habit_missed",
339
+ title: `${parsed.status === "done" ? "Habit completed" : "Habit missed"}: ${habit.title}`,
340
+ description: habit.polarity === "positive"
341
+ ? parsed.status === "done"
342
+ ? "Positive habit logged as completed."
343
+ : "Positive habit logged as missed."
344
+ : parsed.status === "done"
345
+ ? "Negative habit logged as performed."
346
+ : "Negative habit logged as resisted.",
347
+ actor: activity?.actor ?? null,
348
+ source: activity?.source ?? "ui",
349
+ metadata: {
350
+ dateKey: parsed.dateKey,
351
+ status: parsed.status,
352
+ polarity: habit.polarity,
353
+ deltaXp: reward.deltaXp
354
+ }
355
+ });
356
+ return getHabitById(habitId);
357
+ });
358
+ }
@@ -43,6 +43,22 @@ const DEFAULT_RULES = [
43
43
  description: "Reward a concrete decision to apply a useful insight.",
44
44
  config: { fixedXp: 15 }
45
45
  },
46
+ {
47
+ id: "reward_rule_habit_aligned",
48
+ family: "consistency",
49
+ code: "habit_aligned",
50
+ title: "Habit alignment",
51
+ description: "Award XP when a habit outcome matches the intended direction.",
52
+ config: { award: "habit.rewardXp" }
53
+ },
54
+ {
55
+ id: "reward_rule_habit_misaligned",
56
+ family: "recovery",
57
+ code: "habit_misaligned",
58
+ title: "Habit miss",
59
+ description: "Apply a small XP penalty when a habit outcome moves against the intended direction.",
60
+ config: { penalty: "habit.penaltyXp" }
61
+ },
46
62
  {
47
63
  id: "reward_rule_psyche_reflection_capture",
48
64
  family: "alignment",
@@ -633,6 +649,52 @@ export function recordSessionEvent(input, activity, now = new Date()) {
633
649
  }, now);
634
650
  return { sessionEvent, rewardEvent };
635
651
  }
652
+ export function recordHabitCheckInReward(habit, status, dateKey, activity) {
653
+ ensureDefaultRewardRules();
654
+ const aligned = (habit.polarity === "positive" && status === "done") ||
655
+ (habit.polarity === "negative" && status === "missed");
656
+ const rule = getRuleByCode(aligned ? "habit_aligned" : "habit_misaligned");
657
+ const deltaXp = aligned ? habit.rewardXp : -Math.abs(habit.penaltyXp);
658
+ const actionLabel = habit.polarity === "positive"
659
+ ? status === "done"
660
+ ? "completed"
661
+ : "missed"
662
+ : status === "done"
663
+ ? "performed"
664
+ : "resisted";
665
+ const eventLog = recordEventLog({
666
+ eventKind: aligned ? "reward.habit_aligned" : "reward.habit_misaligned",
667
+ entityType: "habit",
668
+ entityId: habit.id,
669
+ actor: activity.actor ?? null,
670
+ source: activity.source,
671
+ metadata: {
672
+ habitId: habit.id,
673
+ status,
674
+ polarity: habit.polarity,
675
+ dateKey,
676
+ deltaXp
677
+ }
678
+ });
679
+ return insertLedgerEvent({
680
+ ruleId: rule?.id ?? null,
681
+ eventLogId: eventLog.id,
682
+ entityType: "habit",
683
+ entityId: habit.id,
684
+ actor: activity.actor ?? null,
685
+ source: activity.source,
686
+ deltaXp,
687
+ reasonTitle: aligned ? `${habit.title} aligned` : `${habit.title} slipped`,
688
+ reasonSummary: `Habit ${actionLabel} on ${dateKey}.`,
689
+ reversibleGroup: `habit:${habit.id}:${dateKey}`,
690
+ metadata: {
691
+ habitId: habit.id,
692
+ status,
693
+ polarity: habit.polarity,
694
+ dateKey
695
+ }
696
+ });
697
+ }
636
698
  export function listSessionEvents(limit = 50) {
637
699
  const rows = getDatabase()
638
700
  .prepare(`SELECT id, session_id, event_type, actor, source, metrics_json, created_at
@@ -1,5 +1,7 @@
1
1
  import { listActivityEvents } from "../repositories/activity-events.js";
2
2
  import { listGoals } from "../repositories/goals.js";
3
+ import { listHabits } from "../repositories/habits.js";
4
+ import { listRewardLedger } from "../repositories/rewards.js";
3
5
  import { listTags, listTagsByIds } from "../repositories/tags.js";
4
6
  import { listTasks } from "../repositories/tasks.js";
5
7
  import { getDashboard } from "./dashboard.js";
@@ -109,6 +111,7 @@ export function getOverviewContext(now = new Date()) {
109
111
  const dashboard = getDashboard();
110
112
  const focusTasks = dashboard.tasks.filter((task) => task.status === "focus" || task.status === "in_progress").length;
111
113
  const overdueTasks = dashboard.tasks.filter((task) => task.status !== "done" && task.dueDate !== null && task.dueDate < now.toISOString().slice(0, 10)).length;
114
+ const dueHabits = dashboard.habits.filter((habit) => habit.dueToday).slice(0, 6);
112
115
  return overviewContextSchema.parse({
113
116
  generatedAt: now.toISOString(),
114
117
  strategicHeader: {
@@ -124,8 +127,9 @@ export function getOverviewContext(now = new Date()) {
124
127
  projects: dashboard.projects.slice(0, 5),
125
128
  activeGoals: dashboard.goals.filter((goal) => goal.status === "active").slice(0, 6),
126
129
  topTasks: sortStrategicTasks(dashboard.tasks.filter((task) => task.status !== "done")).slice(0, 6),
130
+ dueHabits,
127
131
  recentEvidence: listActivityEvents({ limit: 12 }),
128
- achievements: buildAchievementSignals(listGoals(), listTasks(), now),
132
+ achievements: buildAchievementSignals(listGoals(), listTasks(), listHabits(), now),
129
133
  domainBalance: buildDomainBalance(listGoals(), listTasks()),
130
134
  neglectedGoals: buildNeglectedGoals(listGoals(), listTasks(), now)
131
135
  });
@@ -133,10 +137,12 @@ export function getOverviewContext(now = new Date()) {
133
137
  export function getTodayContext(now = new Date()) {
134
138
  const goals = listGoals();
135
139
  const tasks = listTasks();
136
- const gamification = buildGamificationProfile(goals, tasks, now);
140
+ const habits = listHabits();
141
+ const gamification = buildGamificationProfile(goals, tasks, habits, now);
137
142
  const inProgressTasks = sortStrategicTasks(tasks.filter((task) => task.status === "in_progress")).slice(0, 4);
138
143
  const readyTasks = sortStrategicTasks(tasks.filter((task) => task.status === "focus" || task.status === "backlog")).slice(0, 4);
139
144
  const deferredTasks = sortStrategicTasks(tasks.filter((task) => task.status === "blocked")).slice(0, 4);
145
+ const dueHabits = habits.filter((habit) => habit.dueToday).slice(0, 6);
140
146
  const completedTasks = [...tasks]
141
147
  .filter((task) => task.status === "done" && task.completedAt !== null)
142
148
  .sort((left, right) => Date.parse(right.completedAt ?? "") - Date.parse(left.completedAt ?? ""))
@@ -185,13 +191,17 @@ export function getTodayContext(now = new Date()) {
185
191
  completed: false
186
192
  }
187
193
  ],
188
- milestoneRewards: buildMilestoneRewards(goals, tasks, now),
194
+ dueHabits,
195
+ milestoneRewards: buildMilestoneRewards(goals, tasks, habits, now),
196
+ recentHabitRewards: listRewardLedger({ entityType: "habit", limit: 8 }),
189
197
  momentum: {
190
198
  streakDays: gamification.streakDays,
191
199
  momentumScore: gamification.momentumScore,
192
- recoveryHint: overdueCount > 0
193
- ? `Clear ${overdueCount} overdue task${overdueCount === 1 ? "" : "s"} to keep momentum from decaying.`
194
- : "No overdue drag right now. Preserve the rhythm with one decisive completion."
200
+ recoveryHint: dueHabits.length > 0
201
+ ? `${dueHabits.length} habit${dueHabits.length === 1 ? "" : "s"} still need a check-in today. Closing one will keep momentum honest.`
202
+ : overdueCount > 0
203
+ ? `Clear ${overdueCount} overdue task${overdueCount === 1 ? "" : "s"} to keep momentum from decaying.`
204
+ : "No overdue drag right now. Preserve the rhythm with one decisive completion."
195
205
  }
196
206
  });
197
207
  }
@@ -1,5 +1,6 @@
1
1
  import { listGoals } from "../repositories/goals.js";
2
2
  import { listActivityEvents } from "../repositories/activity-events.js";
3
+ import { listHabits } from "../repositories/habits.js";
3
4
  import { buildNotesSummaryByEntity } from "../repositories/notes.js";
4
5
  import { listTagsByIds, listTags } from "../repositories/tags.js";
5
6
  import { listTasks } from "../repositories/tasks.js";
@@ -116,6 +117,7 @@ function buildGoalSummary(tasks, goalId) {
116
117
  export function getDashboard() {
117
118
  const goals = listGoals();
118
119
  const tasks = listTasks();
120
+ const habits = listHabits();
119
121
  const tags = listTags();
120
122
  const now = new Date();
121
123
  const weekStart = startOfWeek(now).toISOString();
@@ -150,9 +152,9 @@ export function getDashboard() {
150
152
  const suggestedTags = tags.filter((tag) => ["value", "execution"].includes(tag.kind)).slice(0, 6);
151
153
  const owners = [...new Set(tasks.map((task) => task.owner).filter(Boolean))].sort((left, right) => left.localeCompare(right));
152
154
  const executionBuckets = buildExecutionBuckets(tasks, todayIso, weekEndIso);
153
- const gamification = buildGamificationProfile(goals, tasks, now);
154
- const achievements = buildAchievementSignals(goals, tasks, now);
155
- const milestoneRewards = buildMilestoneRewards(goals, tasks, now);
155
+ const gamification = buildGamificationProfile(goals, tasks, habits, now);
156
+ const achievements = buildAchievementSignals(goals, tasks, habits, now);
157
+ const milestoneRewards = buildMilestoneRewards(goals, tasks, habits, now);
156
158
  const recentActivity = listActivityEvents({ limit: 12 });
157
159
  const notesSummaryByEntity = buildNotesSummaryByEntity();
158
160
  return dashboardPayloadSchema.parse({
@@ -160,6 +162,7 @@ export function getDashboard() {
160
162
  goals: goalCards,
161
163
  projects,
162
164
  tasks,
165
+ habits,
163
166
  tags,
164
167
  suggestedTags,
165
168
  owners,
@@ -4,11 +4,12 @@ import { createNote, deleteNote, getNoteById, listNotes, unlinkNotesForEntity, u
4
4
  import { createBehaviorPatternSchema, createBehaviorSchema, createBeliefEntrySchema, createEmotionDefinitionSchema, createEventTypeSchema, createModeGuideSessionSchema, createModeProfileSchema, createPsycheValueSchema, createTriggerReportSchema, updateBehaviorPatternSchema, updateBehaviorSchema, updateBeliefEntrySchema, updateEmotionDefinitionSchema, updateEventTypeSchema, updateModeGuideSessionSchema, updateModeProfileSchema, updatePsycheValueSchema, updateTriggerReportSchema } from "../psyche-types.js";
5
5
  import { buildSettingsBinPayload, cascadeSoftDeleteAnchoredCollaboration, clearDeletedEntityRecord, getDeletedEntityRecord, listDeletedEntities, restoreAnchoredCollaboration, restoreDeletedEntityRecord, upsertDeletedEntityRecord } from "../repositories/deleted-entities.js";
6
6
  import { createGoal, deleteGoal, getGoalById, listGoals, updateGoal } from "../repositories/goals.js";
7
+ import { createHabit, deleteHabit, getHabitById, listHabits, updateHabit } from "../repositories/habits.js";
7
8
  import { createBehavior, createBehaviorPattern, createBeliefEntry, createEmotionDefinition, createEventType, createModeGuideSession, createModeProfile, createPsycheValue, createTriggerReport, deleteBehavior, deleteBehaviorPattern, deleteBeliefEntry, deleteEmotionDefinition, deleteEventType, deleteModeGuideSession, deleteModeProfile, deletePsycheValue, deleteTriggerReport, getBehaviorById, getBehaviorPatternById, getBeliefEntryById, getEmotionDefinitionById, getEventTypeById, getModeGuideSessionById, getModeProfileById, getPsycheValueById, getTriggerReportById, listBehaviors, listBehaviorPatterns, listBeliefEntries, listEmotionDefinitions, listEventTypes, listModeGuideSessions, listModeProfiles, listPsycheValues, listTriggerReports, updateBehavior, updateBehaviorPattern, updateBeliefEntry, updateEmotionDefinition, updateEventType, updateModeGuideSession, updateModeProfile, updatePsycheValue, updateTriggerReport } from "../repositories/psyche.js";
8
9
  import { createProject, deleteProject, getProjectById, listProjects, updateProject } from "../repositories/projects.js";
9
10
  import { createTag, deleteTag, getTagById, listTags, updateTag } from "../repositories/tags.js";
10
11
  import { createTask, deleteTask, getTaskById, listTasks, updateTask } from "../repositories/tasks.js";
11
- import { createGoalSchema, createInsightSchema, createNoteSchema, createProjectSchema, createTagSchema, createTaskSchema, updateGoalSchema, updateInsightSchema, updateNoteSchema, updateProjectSchema, updateTagSchema, updateTaskSchema } from "../types.js";
12
+ import { createGoalSchema, createHabitSchema, createInsightSchema, createNoteSchema, createProjectSchema, createTagSchema, createTaskSchema, updateGoalSchema, updateHabitSchema, updateInsightSchema, updateNoteSchema, updateProjectSchema, updateTagSchema, updateTaskSchema } from "../types.js";
12
13
  class AtomicBatchRollback extends Error {
13
14
  index;
14
15
  code;
@@ -49,6 +50,15 @@ const CRUD_ENTITY_CAPABILITIES = {
49
50
  update: (id, patch, context) => updateTask(id, patch, context),
50
51
  hardDelete: (id, context) => deleteTask(id, context)
51
52
  },
53
+ habit: {
54
+ entityType: "habit",
55
+ routeBase: "/api/v1/habits",
56
+ list: () => listHabits(),
57
+ get: (id) => getHabitById(id),
58
+ create: (data, context) => createHabit(data, context),
59
+ update: (id, patch, context) => updateHabit(id, patch, context),
60
+ hardDelete: (id, context) => deleteHabit(id, context)
61
+ },
52
62
  tag: {
53
63
  entityType: "tag",
54
64
  routeBase: "/api/v1/tags",
@@ -174,6 +184,7 @@ const CREATE_ENTITY_SCHEMAS = {
174
184
  goal: createGoalSchema,
175
185
  project: createProjectSchema,
176
186
  task: createTaskSchema,
187
+ habit: createHabitSchema,
177
188
  tag: createTagSchema,
178
189
  note: createNoteSchema,
179
190
  insight: createInsightSchema,
@@ -191,6 +202,7 @@ const UPDATE_ENTITY_SCHEMAS = {
191
202
  goal: updateGoalSchema,
192
203
  project: updateProjectSchema,
193
204
  task: updateTaskSchema,
205
+ habit: updateHabitSchema,
194
206
  tag: updateTagSchema,
195
207
  note: updateNoteSchema,
196
208
  insight: updateInsightSchema,
@@ -312,6 +324,16 @@ function matchesLinkedTo(entityType, entity, linkedTo) {
312
324
  return linkedTo.entityType === "goal" && entity.goalId === linkedTo.id;
313
325
  case "task":
314
326
  return (linkedTo.entityType === "goal" && entity.goalId === linkedTo.id) || (linkedTo.entityType === "project" && entity.projectId === linkedTo.id);
327
+ case "habit":
328
+ return ((linkedTo.entityType === "goal" && Array.isArray(entity.linkedGoalIds) && entity.linkedGoalIds.includes(linkedTo.id)) ||
329
+ (linkedTo.entityType === "project" && Array.isArray(entity.linkedProjectIds) && entity.linkedProjectIds.includes(linkedTo.id)) ||
330
+ (linkedTo.entityType === "task" && Array.isArray(entity.linkedTaskIds) && entity.linkedTaskIds.includes(linkedTo.id)) ||
331
+ (linkedTo.entityType === "psyche_value" && Array.isArray(entity.linkedValueIds) && entity.linkedValueIds.includes(linkedTo.id)) ||
332
+ (linkedTo.entityType === "behavior_pattern" && Array.isArray(entity.linkedPatternIds) && entity.linkedPatternIds.includes(linkedTo.id)) ||
333
+ (linkedTo.entityType === "behavior" && Array.isArray(entity.linkedBehaviorIds) && entity.linkedBehaviorIds.includes(linkedTo.id)) ||
334
+ (linkedTo.entityType === "belief_entry" && Array.isArray(entity.linkedBeliefIds) && entity.linkedBeliefIds.includes(linkedTo.id)) ||
335
+ (linkedTo.entityType === "mode_profile" && Array.isArray(entity.linkedModeIds) && entity.linkedModeIds.includes(linkedTo.id)) ||
336
+ (linkedTo.entityType === "trigger_report" && Array.isArray(entity.linkedReportIds) && entity.linkedReportIds.includes(linkedTo.id)));
315
337
  case "note":
316
338
  return (Array.isArray(entity.links) &&
317
339
  entity.links.some((link) => typeof link === "object" &&