forge-openclaw-plugin 0.2.15 → 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 (41) hide show
  1. package/README.md +6 -3
  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/local-runtime.js +142 -9
  19. package/dist/openclaw/plugin-entry-shared.js +7 -1
  20. package/dist/openclaw/tools.js +15 -0
  21. package/dist/server/app.js +129 -11
  22. package/dist/server/openapi.js +181 -4
  23. package/dist/server/repositories/habits.js +358 -0
  24. package/dist/server/repositories/rewards.js +62 -0
  25. package/dist/server/services/context.js +16 -6
  26. package/dist/server/services/dashboard.js +6 -3
  27. package/dist/server/services/entity-crud.js +23 -1
  28. package/dist/server/services/gamification.js +66 -18
  29. package/dist/server/services/insights.js +2 -1
  30. package/dist/server/services/reviews.js +2 -1
  31. package/dist/server/types.js +140 -1
  32. package/openclaw.plugin.json +1 -1
  33. package/package.json +1 -1
  34. package/server/migrations/003_habits.sql +30 -0
  35. package/server/migrations/004_habit_links.sql +8 -0
  36. package/server/migrations/005_habit_psyche_links.sql +24 -0
  37. package/skills/forge-openclaw/SKILL.md +16 -2
  38. package/skills/forge-openclaw/cron_jobs.md +395 -0
  39. package/dist/assets/index-BWtLtXwb.js +0 -36
  40. package/dist/assets/index-BWtLtXwb.js.map +0 -1
  41. package/dist/assets/index-Dp5GXY_z.css +0 -1
@@ -242,6 +242,89 @@ export function buildOpenApiDocument() {
242
242
  isCurrent: { type: "boolean" }
243
243
  }
244
244
  };
245
+ const habitCheckIn = {
246
+ type: "object",
247
+ additionalProperties: false,
248
+ required: ["id", "habitId", "dateKey", "status", "note", "deltaXp", "createdAt", "updatedAt"],
249
+ properties: {
250
+ id: { type: "string" },
251
+ habitId: { type: "string" },
252
+ dateKey: { type: "string", format: "date" },
253
+ status: { type: "string", enum: ["done", "missed"] },
254
+ note: { type: "string" },
255
+ deltaXp: { type: "integer" },
256
+ createdAt: { type: "string", format: "date-time" },
257
+ updatedAt: { type: "string", format: "date-time" }
258
+ }
259
+ };
260
+ const habit = {
261
+ type: "object",
262
+ additionalProperties: false,
263
+ required: [
264
+ "id",
265
+ "title",
266
+ "description",
267
+ "status",
268
+ "polarity",
269
+ "frequency",
270
+ "targetCount",
271
+ "weekDays",
272
+ "linkedGoalIds",
273
+ "linkedProjectIds",
274
+ "linkedTaskIds",
275
+ "linkedValueIds",
276
+ "linkedPatternIds",
277
+ "linkedBehaviorIds",
278
+ "linkedBeliefIds",
279
+ "linkedModeIds",
280
+ "linkedReportIds",
281
+ "linkedBehaviorId",
282
+ "linkedBehaviorTitle",
283
+ "linkedBehaviorTitles",
284
+ "rewardXp",
285
+ "penaltyXp",
286
+ "createdAt",
287
+ "updatedAt",
288
+ "lastCheckInAt",
289
+ "lastCheckInStatus",
290
+ "streakCount",
291
+ "completionRate",
292
+ "dueToday",
293
+ "checkIns"
294
+ ],
295
+ properties: {
296
+ id: { type: "string" },
297
+ title: { type: "string" },
298
+ description: { type: "string" },
299
+ status: { type: "string", enum: ["active", "paused", "archived"] },
300
+ polarity: { type: "string", enum: ["positive", "negative"] },
301
+ frequency: { type: "string", enum: ["daily", "weekly"] },
302
+ targetCount: { type: "integer" },
303
+ weekDays: arrayOf({ type: "integer" }),
304
+ linkedGoalIds: arrayOf({ type: "string" }),
305
+ linkedProjectIds: arrayOf({ type: "string" }),
306
+ linkedTaskIds: arrayOf({ type: "string" }),
307
+ linkedValueIds: arrayOf({ type: "string" }),
308
+ linkedPatternIds: arrayOf({ type: "string" }),
309
+ linkedBehaviorIds: arrayOf({ type: "string" }),
310
+ linkedBeliefIds: arrayOf({ type: "string" }),
311
+ linkedModeIds: arrayOf({ type: "string" }),
312
+ linkedReportIds: arrayOf({ type: "string" }),
313
+ linkedBehaviorId: nullable({ type: "string" }),
314
+ linkedBehaviorTitle: nullable({ type: "string" }),
315
+ linkedBehaviorTitles: arrayOf({ type: "string" }),
316
+ rewardXp: { type: "integer" },
317
+ penaltyXp: { type: "integer" },
318
+ createdAt: { type: "string", format: "date-time" },
319
+ updatedAt: { type: "string", format: "date-time" },
320
+ lastCheckInAt: nullable({ type: "string", format: "date-time" }),
321
+ lastCheckInStatus: nullable({ type: "string", enum: ["done", "missed"] }),
322
+ streakCount: { type: "integer" },
323
+ completionRate: { type: "number" },
324
+ dueToday: { type: "boolean" },
325
+ checkIns: arrayOf({ $ref: "#/components/schemas/HabitCheckIn" })
326
+ }
327
+ };
245
328
  const activityEvent = {
246
329
  type: "object",
247
330
  additionalProperties: false,
@@ -252,6 +335,7 @@ export function buildOpenApiDocument() {
252
335
  type: "string",
253
336
  enum: [
254
337
  "task",
338
+ "habit",
255
339
  "goal",
256
340
  "project",
257
341
  "domain",
@@ -378,7 +462,7 @@ export function buildOpenApiDocument() {
378
462
  const dashboardPayload = {
379
463
  type: "object",
380
464
  additionalProperties: false,
381
- required: ["stats", "goals", "projects", "tasks", "tags", "suggestedTags", "owners", "executionBuckets", "gamification", "achievements", "milestoneRewards", "recentActivity", "notesSummaryByEntity"],
465
+ required: ["stats", "goals", "projects", "tasks", "habits", "tags", "suggestedTags", "owners", "executionBuckets", "gamification", "achievements", "milestoneRewards", "recentActivity", "notesSummaryByEntity"],
382
466
  properties: {
383
467
  stats: {
384
468
  type: "object",
@@ -397,6 +481,7 @@ export function buildOpenApiDocument() {
397
481
  goals: arrayOf({ $ref: "#/components/schemas/DashboardGoal" }),
398
482
  projects: arrayOf({ $ref: "#/components/schemas/ProjectSummary" }),
399
483
  tasks: arrayOf({ $ref: "#/components/schemas/Task" }),
484
+ habits: arrayOf({ $ref: "#/components/schemas/Habit" }),
400
485
  tags: arrayOf({ $ref: "#/components/schemas/Tag" }),
401
486
  suggestedTags: arrayOf({ $ref: "#/components/schemas/Tag" }),
402
487
  owners: arrayOf({ type: "string" }),
@@ -422,7 +507,7 @@ export function buildOpenApiDocument() {
422
507
  const overviewContext = {
423
508
  type: "object",
424
509
  additionalProperties: false,
425
- required: ["generatedAt", "strategicHeader", "projects", "activeGoals", "topTasks", "recentEvidence", "achievements", "domainBalance", "neglectedGoals"],
510
+ required: ["generatedAt", "strategicHeader", "projects", "activeGoals", "topTasks", "dueHabits", "recentEvidence", "achievements", "domainBalance", "neglectedGoals"],
426
511
  properties: {
427
512
  generatedAt: { type: "string", format: "date-time" },
428
513
  strategicHeader: {
@@ -443,6 +528,7 @@ export function buildOpenApiDocument() {
443
528
  projects: arrayOf({ $ref: "#/components/schemas/ProjectSummary" }),
444
529
  activeGoals: arrayOf({ $ref: "#/components/schemas/DashboardGoal" }),
445
530
  topTasks: arrayOf({ $ref: "#/components/schemas/Task" }),
531
+ dueHabits: arrayOf({ $ref: "#/components/schemas/Habit" }),
446
532
  recentEvidence: arrayOf({ $ref: "#/components/schemas/ActivityEvent" }),
447
533
  achievements: arrayOf({ $ref: "#/components/schemas/AchievementSignal" }),
448
534
  domainBalance: arrayOf({
@@ -475,7 +561,7 @@ export function buildOpenApiDocument() {
475
561
  const todayContext = {
476
562
  type: "object",
477
563
  additionalProperties: false,
478
- required: ["generatedAt", "directive", "timeline", "dailyQuests", "milestoneRewards", "momentum"],
564
+ required: ["generatedAt", "directive", "timeline", "dueHabits", "dailyQuests", "milestoneRewards", "recentHabitRewards", "momentum"],
479
565
  properties: {
480
566
  generatedAt: { type: "string", format: "date-time" },
481
567
  directive: {
@@ -499,6 +585,7 @@ export function buildOpenApiDocument() {
499
585
  tasks: arrayOf({ $ref: "#/components/schemas/Task" })
500
586
  }
501
587
  }),
588
+ dueHabits: arrayOf({ $ref: "#/components/schemas/Habit" }),
502
589
  dailyQuests: arrayOf({
503
590
  type: "object",
504
591
  additionalProperties: false,
@@ -513,6 +600,7 @@ export function buildOpenApiDocument() {
513
600
  }
514
601
  }),
515
602
  milestoneRewards: arrayOf({ $ref: "#/components/schemas/MilestoneReward" }),
603
+ recentHabitRewards: arrayOf({ $ref: "#/components/schemas/RewardLedgerEvent" }),
516
604
  momentum: {
517
605
  type: "object",
518
606
  additionalProperties: false,
@@ -550,7 +638,7 @@ export function buildOpenApiDocument() {
550
638
  const forgeSnapshot = {
551
639
  type: "object",
552
640
  additionalProperties: false,
553
- required: ["meta", "metrics", "dashboard", "overview", "today", "risk", "goals", "projects", "tags", "tasks", "activeTaskRuns", "activity"],
641
+ required: ["meta", "metrics", "dashboard", "overview", "today", "risk", "goals", "projects", "tags", "tasks", "habits", "activeTaskRuns", "activity"],
554
642
  properties: {
555
643
  meta: {
556
644
  type: "object",
@@ -573,6 +661,7 @@ export function buildOpenApiDocument() {
573
661
  projects: arrayOf({ $ref: "#/components/schemas/ProjectSummary" }),
574
662
  tags: arrayOf({ $ref: "#/components/schemas/Tag" }),
575
663
  tasks: arrayOf({ $ref: "#/components/schemas/Task" }),
664
+ habits: arrayOf({ $ref: "#/components/schemas/Habit" }),
576
665
  activeTaskRuns: arrayOf({ $ref: "#/components/schemas/TaskRun" }),
577
666
  activity: arrayOf({ $ref: "#/components/schemas/ActivityEvent" })
578
667
  }
@@ -1041,6 +1130,7 @@ export function buildOpenApiDocument() {
1041
1130
  "generatedAt",
1042
1131
  "activeProjects",
1043
1132
  "focusTasks",
1133
+ "dueHabits",
1044
1134
  "currentBoard",
1045
1135
  "recentActivity",
1046
1136
  "recentTaskRuns",
@@ -1051,6 +1141,7 @@ export function buildOpenApiDocument() {
1051
1141
  generatedAt: { type: "string", format: "date-time" },
1052
1142
  activeProjects: arrayOf({ $ref: "#/components/schemas/ProjectSummary" }),
1053
1143
  focusTasks: arrayOf({ $ref: "#/components/schemas/Task" }),
1144
+ dueHabits: arrayOf({ $ref: "#/components/schemas/Habit" }),
1054
1145
  currentBoard: {
1055
1146
  type: "object",
1056
1147
  additionalProperties: false,
@@ -1993,6 +2084,8 @@ export function buildOpenApiDocument() {
1993
2084
  ProjectSummary: projectSummary,
1994
2085
  Task: task,
1995
2086
  TaskRun: taskRun,
2087
+ HabitCheckIn: habitCheckIn,
2088
+ Habit: habit,
1996
2089
  ActivityEvent: activityEvent,
1997
2090
  GamificationProfile: gamificationProfile,
1998
2091
  AchievementSignal: achievementSignal,
@@ -2718,6 +2811,90 @@ export function buildOpenApiDocument() {
2718
2811
  }
2719
2812
  }
2720
2813
  },
2814
+ "/api/v1/habits": {
2815
+ get: {
2816
+ summary: "List habits with current streak and due-today state",
2817
+ responses: {
2818
+ "200": jsonResponse({
2819
+ type: "object",
2820
+ required: ["habits"],
2821
+ properties: {
2822
+ habits: arrayOf({ $ref: "#/components/schemas/Habit" })
2823
+ }
2824
+ }, "Habit collection")
2825
+ }
2826
+ },
2827
+ post: {
2828
+ summary: "Create a habit",
2829
+ responses: {
2830
+ "201": jsonResponse({
2831
+ type: "object",
2832
+ required: ["habit"],
2833
+ properties: {
2834
+ habit: { $ref: "#/components/schemas/Habit" }
2835
+ }
2836
+ }, "Created habit"),
2837
+ default: { $ref: "#/components/responses/Error" }
2838
+ }
2839
+ }
2840
+ },
2841
+ "/api/v1/habits/{id}": {
2842
+ get: {
2843
+ summary: "Get a habit",
2844
+ responses: {
2845
+ "200": jsonResponse({
2846
+ type: "object",
2847
+ required: ["habit"],
2848
+ properties: {
2849
+ habit: { $ref: "#/components/schemas/Habit" }
2850
+ }
2851
+ }, "Habit"),
2852
+ "404": { $ref: "#/components/responses/Error" }
2853
+ }
2854
+ },
2855
+ patch: {
2856
+ summary: "Update a habit",
2857
+ responses: {
2858
+ "200": jsonResponse({
2859
+ type: "object",
2860
+ required: ["habit"],
2861
+ properties: {
2862
+ habit: { $ref: "#/components/schemas/Habit" }
2863
+ }
2864
+ }, "Updated habit"),
2865
+ "404": { $ref: "#/components/responses/Error" }
2866
+ }
2867
+ },
2868
+ delete: {
2869
+ summary: "Delete a habit",
2870
+ responses: {
2871
+ "200": jsonResponse({
2872
+ type: "object",
2873
+ required: ["habit"],
2874
+ properties: {
2875
+ habit: { $ref: "#/components/schemas/Habit" }
2876
+ }
2877
+ }, "Deleted habit"),
2878
+ "404": { $ref: "#/components/responses/Error" }
2879
+ }
2880
+ }
2881
+ },
2882
+ "/api/v1/habits/{id}/check-ins": {
2883
+ post: {
2884
+ summary: "Record a habit outcome for one day",
2885
+ responses: {
2886
+ "200": jsonResponse({
2887
+ type: "object",
2888
+ required: ["habit", "metrics"],
2889
+ properties: {
2890
+ habit: { $ref: "#/components/schemas/Habit" },
2891
+ metrics: { $ref: "#/components/schemas/XpMetricsPayload" }
2892
+ }
2893
+ }, "Habit check-in result"),
2894
+ "404": { $ref: "#/components/responses/Error" }
2895
+ }
2896
+ }
2897
+ },
2721
2898
  "/api/v1/tags": {
2722
2899
  get: {
2723
2900
  summary: "List tags",
@@ -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
+ }