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
@@ -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" &&
@@ -12,9 +12,15 @@ function startOfWeek(date) {
12
12
  function dayKey(isoDate) {
13
13
  return isoDate.slice(0, 10);
14
14
  }
15
- function calculateStreak(tasks, now) {
16
- const completedDays = new Set(tasks
17
- .flatMap((task) => (task.status === "done" && task.completedAt !== null ? [dayKey(task.completedAt)] : [])));
15
+ function isAlignedHabitCheckIn(habit, checkIn) {
16
+ return (habit.polarity === "positive" && checkIn.status === "done") || (habit.polarity === "negative" && checkIn.status === "missed");
17
+ }
18
+ function calculateStreak(tasks, habits, now) {
19
+ const completedDays = new Set([
20
+ ...tasks
21
+ .flatMap((task) => (task.status === "done" && task.completedAt !== null ? [dayKey(task.completedAt)] : [])),
22
+ ...habits.flatMap((habit) => habit.checkIns.filter((checkIn) => isAlignedHabitCheckIn(habit, checkIn)).map((checkIn) => checkIn.dateKey))
23
+ ]);
18
24
  if (completedDays.size === 0) {
19
25
  return 0;
20
26
  }
@@ -41,17 +47,27 @@ function latestCompletionForTasks(tasks) {
41
47
  .flatMap((task) => (task.completedAt ? [task.completedAt] : []))
42
48
  .sort((left, right) => Date.parse(right) - Date.parse(left))[0] ?? null;
43
49
  }
44
- export function buildGamificationProfile(goals, tasks, now = new Date()) {
50
+ function latestAlignedHabitAt(habits) {
51
+ return habits
52
+ .flatMap((habit) => habit.checkIns
53
+ .filter((checkIn) => isAlignedHabitCheckIn(habit, checkIn))
54
+ .map((checkIn) => checkIn.createdAt))
55
+ .sort((left, right) => Date.parse(right) - Date.parse(left))[0] ?? null;
56
+ }
57
+ export function buildGamificationProfile(goals, tasks, habits, now = new Date()) {
45
58
  const weekStart = startOfWeek(now).toISOString();
46
59
  const doneTasks = tasks.filter((task) => task.status === "done");
47
60
  const totalXp = getTotalXp();
48
61
  const weeklyXp = getWeeklyXp(weekStart);
49
62
  const focusTasks = tasks.filter((task) => task.status === "focus" || task.status === "in_progress").length;
50
63
  const overdueTasks = tasks.filter((task) => task.status !== "done" && task.dueDate !== null && task.dueDate < now.toISOString().slice(0, 10)).length;
64
+ const dueHabits = habits.filter((habit) => habit.dueToday).length;
65
+ const alignedHabitCheckIns = habits.flatMap((habit) => habit.checkIns.filter((checkIn) => isAlignedHabitCheckIn(habit, checkIn)));
66
+ const habitMomentum = habits.reduce((sum, habit) => sum + habit.streakCount * 3 + (habit.dueToday ? -4 : 2), 0);
51
67
  const alignedDonePoints = doneTasks
52
68
  .filter((task) => task.goalId !== null && task.tagIds.length > 0)
53
69
  .reduce((sum, task) => sum + task.points, 0);
54
- const streakDays = calculateStreak(tasks, now);
70
+ const streakDays = calculateStreak(tasks, habits, now);
55
71
  const levelState = calculateLevel(totalXp);
56
72
  const goalScores = goals
57
73
  .map((goal) => ({
@@ -69,17 +85,20 @@ export function buildGamificationProfile(goals, tasks, now = new Date()) {
69
85
  weeklyXp,
70
86
  streakDays,
71
87
  comboMultiplier: Number((1 + Math.min(0.75, streakDays * 0.05)).toFixed(2)),
72
- momentumScore: Math.max(0, Math.min(100, Math.round(weeklyXp / 6 + alignedDonePoints / 20 + focusTasks * 5 - overdueTasks * 9))),
88
+ momentumScore: Math.max(0, Math.min(100, Math.round(weeklyXp / 6 + alignedDonePoints / 20 + focusTasks * 5 + alignedHabitCheckIns.length * 4 + habitMomentum - overdueTasks * 9 - dueHabits * 3))),
73
89
  topGoalId: topGoal?.goalId ?? null,
74
90
  topGoalTitle: topGoal?.goalTitle ?? null
75
91
  });
76
92
  }
77
- export function buildAchievementSignals(goals, tasks, now = new Date()) {
78
- const profile = buildGamificationProfile(goals, tasks, now);
93
+ export function buildAchievementSignals(goals, tasks, habits, now = new Date()) {
94
+ const profile = buildGamificationProfile(goals, tasks, habits, now);
79
95
  const doneTasks = tasks.filter((task) => task.status === "done");
80
96
  const alignedDoneTasks = doneTasks.filter((task) => task.goalId !== null && task.tagIds.length > 0);
81
97
  const focusTasks = tasks.filter((task) => task.status === "focus" || task.status === "in_progress");
82
98
  const highValueGoals = goals.filter((goal) => doneTasks.some((task) => task.goalId === goal.id));
99
+ const alignedHabitCount = habits.reduce((sum, habit) => sum + habit.checkIns.filter((checkIn) => isAlignedHabitCheckIn(habit, checkIn)).length, 0);
100
+ const topHabitStreak = Math.max(0, ...habits.map((habit) => habit.streakCount));
101
+ const latestHabitWin = latestAlignedHabitAt(habits);
83
102
  return [
84
103
  {
85
104
  id: "streak-operator",
@@ -125,15 +144,34 @@ export function buildAchievementSignals(goals, tasks, now = new Date()) {
125
144
  progressLabel: `${Math.min(focusTasks.length, 1)}/1 live directives`,
126
145
  unlocked: focusTasks.length > 0,
127
146
  unlockedAt: focusTasks.length > 0 ? now.toISOString() : null
147
+ },
148
+ {
149
+ id: "habit-keeper",
150
+ title: "Habit Keeper",
151
+ summary: "Turn recurring behavior into visible operating evidence.",
152
+ tier: alignedHabitCount >= 12 ? "gold" : alignedHabitCount >= 6 ? "silver" : "bronze",
153
+ progressLabel: `${Math.min(alignedHabitCount, 12)}/12 aligned habit wins`,
154
+ unlocked: alignedHabitCount >= 12,
155
+ unlockedAt: alignedHabitCount >= 12 ? latestHabitWin : null
156
+ },
157
+ {
158
+ id: "ritual-pressure",
159
+ title: "Ritual Pressure",
160
+ summary: "Keep one habit alive long enough that it changes the texture of the week.",
161
+ tier: topHabitStreak >= 10 ? "gold" : "silver",
162
+ progressLabel: `${Math.min(topHabitStreak, 10)}/10 habit streak`,
163
+ unlocked: topHabitStreak >= 10,
164
+ unlockedAt: topHabitStreak >= 10 ? latestHabitWin : null
128
165
  }
129
166
  ].map((achievement) => achievementSignalSchema.parse(achievement));
130
167
  }
131
- export function buildMilestoneRewards(goals, tasks, now = new Date()) {
132
- const profile = buildGamificationProfile(goals, tasks, now);
168
+ export function buildMilestoneRewards(goals, tasks, habits, now = new Date()) {
169
+ const profile = buildGamificationProfile(goals, tasks, habits, now);
133
170
  const doneTasks = tasks.filter((task) => task.status === "done");
134
171
  const topGoal = profile.topGoalId ? goals.find((goal) => goal.id === profile.topGoalId) ?? null : null;
135
172
  const topGoalXp = topGoal ? doneTasks.filter((task) => task.goalId === topGoal.id).reduce((sum, task) => sum + task.points, 0) : 0;
136
173
  const completedToday = doneTasks.filter((task) => task.completedAt?.slice(0, 10) === now.toISOString().slice(0, 10)).length;
174
+ const alignedHabitCount = habits.reduce((sum, habit) => sum + habit.checkIns.filter((checkIn) => isAlignedHabitCheckIn(habit, checkIn)).length, 0);
137
175
  return [
138
176
  {
139
177
  id: "next-level",
@@ -174,13 +212,23 @@ export function buildMilestoneRewards(goals, tasks, now = new Date()) {
174
212
  current: topGoal ? topGoalXp : 0,
175
213
  target: topGoal ? topGoal.targetPoints : 1,
176
214
  completed: topGoal ? topGoalXp >= topGoal.targetPoints : false
215
+ },
216
+ {
217
+ id: "habit-mass",
218
+ title: "Habit mass threshold",
219
+ summary: "Make recurring behavior part of the same reward engine as tasks and projects.",
220
+ rewardLabel: "Consistency cache +75 xp",
221
+ progressLabel: `${Math.min(alignedHabitCount, 14)}/14 aligned habit check-ins`,
222
+ current: alignedHabitCount,
223
+ target: 14,
224
+ completed: alignedHabitCount >= 14
177
225
  }
178
226
  ].map((reward) => milestoneRewardSchema.parse(reward));
179
227
  }
180
- export function buildXpMomentumPulse(goals, tasks, now = new Date()) {
181
- const profile = buildGamificationProfile(goals, tasks, now);
182
- const achievements = buildAchievementSignals(goals, tasks, now);
183
- const milestoneRewards = buildMilestoneRewards(goals, tasks, now);
228
+ export function buildXpMomentumPulse(goals, tasks, habits, now = new Date()) {
229
+ const profile = buildGamificationProfile(goals, tasks, habits, now);
230
+ const achievements = buildAchievementSignals(goals, tasks, habits, now);
231
+ const milestoneRewards = buildMilestoneRewards(goals, tasks, habits, now);
184
232
  const nextMilestone = milestoneRewards.find((reward) => !reward.completed) ?? milestoneRewards[0] ?? null;
185
233
  const unlockedAchievements = achievements.filter((achievement) => achievement.unlocked).length;
186
234
  const status = profile.momentumScore >= 80 ? "surging" : profile.momentumScore >= 60 ? "steady" : "recovering";
@@ -206,10 +254,10 @@ export function buildXpMomentumPulse(goals, tasks, now = new Date()) {
206
254
  nextMilestoneLabel: nextMilestone?.rewardLabel ?? "Keep building visible momentum"
207
255
  };
208
256
  }
209
- export function buildGamificationOverview(goals, tasks, now = new Date()) {
257
+ export function buildGamificationOverview(goals, tasks, habits, now = new Date()) {
210
258
  return gamificationOverviewSchema.parse({
211
- profile: buildGamificationProfile(goals, tasks, now),
212
- achievements: buildAchievementSignals(goals, tasks, now),
213
- milestoneRewards: buildMilestoneRewards(goals, tasks, now)
259
+ profile: buildGamificationProfile(goals, tasks, habits, now),
260
+ achievements: buildAchievementSignals(goals, tasks, habits, now),
261
+ milestoneRewards: buildMilestoneRewards(goals, tasks, habits, now)
214
262
  });
215
263
  }
@@ -1,6 +1,7 @@
1
1
  import { listActivityEvents } from "../repositories/activity-events.js";
2
2
  import { listInsights } from "../repositories/collaboration.js";
3
3
  import { listGoals } from "../repositories/goals.js";
4
+ import { listHabits } from "../repositories/habits.js";
4
5
  import { listTasks } from "../repositories/tasks.js";
5
6
  import { getOverviewContext } from "./context.js";
6
7
  import { buildGamificationProfile } from "./gamification.js";
@@ -33,7 +34,7 @@ function buildHeatmap(tasks, now) {
33
34
  export function getInsightsPayload(now = new Date()) {
34
35
  const goals = listGoals();
35
36
  const tasks = listTasks();
36
- const gamification = buildGamificationProfile(goals, tasks, now);
37
+ const gamification = buildGamificationProfile(goals, tasks, listHabits(), now);
37
38
  const overview = getOverviewContext(now);
38
39
  const activity = listActivityEvents({ limit: 60 });
39
40
  const trends = Array.from({ length: 6 }, (_, offset) => {
@@ -1,5 +1,6 @@
1
1
  import { listActivityEvents } from "../repositories/activity-events.js";
2
2
  import { listGoals } from "../repositories/goals.js";
3
+ import { listHabits } from "../repositories/habits.js";
3
4
  import { listTasks } from "../repositories/tasks.js";
4
5
  import { buildGamificationProfile } from "./gamification.js";
5
6
  import { weeklyReviewPayloadSchema } from "../types.js";
@@ -36,7 +37,7 @@ function dailyBuckets(tasks, start) {
36
37
  export function getWeeklyReviewPayload(now = new Date()) {
37
38
  const goals = listGoals();
38
39
  const tasks = listTasks();
39
- const gamification = buildGamificationProfile(goals, tasks, now);
40
+ const gamification = buildGamificationProfile(goals, tasks, listHabits(), now);
40
41
  const weekStart = startOfWeek(now);
41
42
  const weekEnd = addDays(weekStart, 6);
42
43
  const weekTasks = tasks.filter((task) => task.updatedAt >= weekStart.toISOString() && task.updatedAt <= addDays(weekEnd, 1).toISOString());
@@ -11,8 +11,13 @@ export const taskDueFilterSchema = z.enum(["overdue", "today", "week"]);
11
11
  export const taskRunStatusSchema = z.enum(["active", "completed", "released", "timed_out"]);
12
12
  export const taskTimerModeSchema = z.enum(["planned", "unlimited"]);
13
13
  export const timeAccountingModeSchema = z.enum(["split", "parallel", "primary_only"]);
14
+ export const habitFrequencySchema = z.enum(["daily", "weekly"]);
15
+ export const habitPolaritySchema = z.enum(["positive", "negative"]);
16
+ export const habitStatusSchema = z.enum(["active", "paused", "archived"]);
17
+ export const habitCheckInStatusSchema = z.enum(["done", "missed"]);
14
18
  export const activityEntityTypeSchema = z.enum([
15
19
  "task",
20
+ "habit",
16
21
  "goal",
17
22
  "project",
18
23
  "domain",
@@ -50,6 +55,7 @@ export const crudEntityTypeSchema = z.enum([
50
55
  "goal",
51
56
  "project",
52
57
  "task",
58
+ "habit",
53
59
  "tag",
54
60
  "note",
55
61
  "insight",
@@ -63,6 +69,22 @@ export const crudEntityTypeSchema = z.enum([
63
69
  "emotion_definition",
64
70
  "trigger_report"
65
71
  ]);
72
+ export const rewardableEntityTypeSchema = z.enum([
73
+ "system",
74
+ "goal",
75
+ "project",
76
+ "task",
77
+ "habit",
78
+ "tag",
79
+ "note",
80
+ "insight",
81
+ "psyche_value",
82
+ "behavior_pattern",
83
+ "behavior",
84
+ "belief_entry",
85
+ "mode_profile",
86
+ "trigger_report"
87
+ ]);
66
88
  export const deleteModeSchema = z.enum(["soft", "hard"]);
67
89
  export const rewardRuleFamilySchema = z.enum([
68
90
  "completion",
@@ -215,6 +237,48 @@ export const taskRunSchema = z.object({
215
237
  timedOutAt: z.string().nullable(),
216
238
  updatedAt: z.string()
217
239
  });
240
+ export const habitCheckInSchema = z.object({
241
+ id: z.string(),
242
+ habitId: z.string(),
243
+ dateKey: z.string(),
244
+ status: habitCheckInStatusSchema,
245
+ note: z.string(),
246
+ deltaXp: z.number().int(),
247
+ createdAt: z.string(),
248
+ updatedAt: z.string()
249
+ });
250
+ export const habitSchema = z.object({
251
+ id: z.string(),
252
+ title: nonEmptyTrimmedString,
253
+ description: trimmedString,
254
+ status: habitStatusSchema,
255
+ polarity: habitPolaritySchema,
256
+ frequency: habitFrequencySchema,
257
+ targetCount: z.number().int().positive(),
258
+ weekDays: z.array(z.number().int().min(0).max(6)).default([]),
259
+ linkedGoalIds: uniqueStringArraySchema.default([]),
260
+ linkedProjectIds: uniqueStringArraySchema.default([]),
261
+ linkedTaskIds: uniqueStringArraySchema.default([]),
262
+ linkedValueIds: uniqueStringArraySchema.default([]),
263
+ linkedPatternIds: uniqueStringArraySchema.default([]),
264
+ linkedBehaviorIds: uniqueStringArraySchema.default([]),
265
+ linkedBeliefIds: uniqueStringArraySchema.default([]),
266
+ linkedModeIds: uniqueStringArraySchema.default([]),
267
+ linkedReportIds: uniqueStringArraySchema.default([]),
268
+ linkedBehaviorId: z.string().nullable(),
269
+ linkedBehaviorTitle: z.string().nullable(),
270
+ linkedBehaviorTitles: z.array(z.string()).default([]),
271
+ rewardXp: z.number().int().positive(),
272
+ penaltyXp: z.number().int().positive(),
273
+ createdAt: z.string(),
274
+ updatedAt: z.string(),
275
+ lastCheckInAt: z.string().nullable(),
276
+ lastCheckInStatus: habitCheckInStatusSchema.nullable(),
277
+ streakCount: z.number().int().nonnegative(),
278
+ completionRate: z.number().min(0).max(100),
279
+ dueToday: z.boolean(),
280
+ checkIns: z.array(habitCheckInSchema).default([])
281
+ });
218
282
  export const activityEventSchema = z.object({
219
283
  id: z.string(),
220
284
  entityType: activityEntityTypeSchema,
@@ -314,6 +378,7 @@ export const dashboardPayloadSchema = z.object({
314
378
  goals: z.array(dashboardGoalSchema),
315
379
  projects: z.array(projectSummarySchema),
316
380
  tasks: z.array(taskSchema),
381
+ habits: z.array(habitSchema),
317
382
  tags: z.array(tagSchema),
318
383
  suggestedTags: z.array(tagSchema),
319
384
  owners: z.array(z.string()),
@@ -354,6 +419,7 @@ export const overviewContextSchema = z.object({
354
419
  projects: z.array(projectSummarySchema),
355
420
  activeGoals: z.array(dashboardGoalSchema),
356
421
  topTasks: z.array(taskSchema),
422
+ dueHabits: z.array(habitSchema),
357
423
  recentEvidence: z.array(activityEventSchema),
358
424
  achievements: z.array(achievementSignalSchema),
359
425
  domainBalance: z.array(contextDomainBalanceSchema),
@@ -381,8 +447,10 @@ export const todayContextSchema = z.object({
381
447
  sessionLabel: z.string()
382
448
  }),
383
449
  timeline: z.array(todayTimelineBucketSchema),
450
+ dueHabits: z.array(habitSchema),
384
451
  dailyQuests: z.array(todayQuestSchema),
385
452
  milestoneRewards: z.array(milestoneRewardSchema),
453
+ recentHabitRewards: z.array(z.lazy(() => rewardLedgerEventSchema)).default([]),
386
454
  momentum: z.object({
387
455
  streakDays: z.number().int().nonnegative(),
388
456
  momentumScore: z.number().int().min(0).max(100),
@@ -656,6 +724,7 @@ export const operatorContextPayloadSchema = z.object({
656
724
  generatedAt: z.string(),
657
725
  activeProjects: z.array(projectSummarySchema),
658
726
  focusTasks: z.array(taskSchema),
727
+ dueHabits: z.array(habitSchema),
659
728
  currentBoard: z.object({
660
729
  backlog: z.array(taskSchema),
661
730
  focus: z.array(taskSchema),
@@ -675,7 +744,7 @@ export const updateRewardRuleSchema = z.object({
675
744
  config: z.record(z.string(), rewardConfigValueSchema).optional()
676
745
  });
677
746
  export const createManualRewardGrantSchema = z.object({
678
- entityType: nonEmptyTrimmedString,
747
+ entityType: rewardableEntityTypeSchema,
679
748
  entityId: nonEmptyTrimmedString,
680
749
  deltaXp: z.number().int().refine((value) => value !== 0, {
681
750
  message: "deltaXp must not be zero"
@@ -777,6 +846,12 @@ export const projectListQuerySchema = z.object({
777
846
  status: projectStatusSchema.optional(),
778
847
  limit: z.coerce.number().int().positive().max(100).optional()
779
848
  });
849
+ export const habitListQuerySchema = z.object({
850
+ status: habitStatusSchema.optional(),
851
+ polarity: habitPolaritySchema.optional(),
852
+ dueToday: z.coerce.boolean().optional(),
853
+ limit: z.coerce.number().int().positive().max(100).optional()
854
+ });
780
855
  export const createGoalSchema = z.object({
781
856
  title: nonEmptyTrimmedString,
782
857
  description: trimmedString.default(""),
@@ -822,6 +897,65 @@ export const taskMutationShape = {
822
897
  notes: z.array(nestedCreateNoteSchema).default([])
823
898
  };
824
899
  export const createTaskSchema = z.object(taskMutationShape);
900
+ const habitMutationShape = {
901
+ title: nonEmptyTrimmedString,
902
+ description: trimmedString.default(""),
903
+ status: habitStatusSchema.default("active"),
904
+ polarity: habitPolaritySchema.default("positive"),
905
+ frequency: habitFrequencySchema.default("daily"),
906
+ targetCount: z.number().int().min(1).max(14).default(1),
907
+ weekDays: z.array(z.number().int().min(0).max(6)).max(7).default([]),
908
+ linkedGoalIds: uniqueStringArraySchema.default([]),
909
+ linkedProjectIds: uniqueStringArraySchema.default([]),
910
+ linkedTaskIds: uniqueStringArraySchema.default([]),
911
+ linkedValueIds: uniqueStringArraySchema.default([]),
912
+ linkedPatternIds: uniqueStringArraySchema.default([]),
913
+ linkedBehaviorIds: uniqueStringArraySchema.default([]),
914
+ linkedBeliefIds: uniqueStringArraySchema.default([]),
915
+ linkedModeIds: uniqueStringArraySchema.default([]),
916
+ linkedReportIds: uniqueStringArraySchema.default([]),
917
+ linkedBehaviorId: nonEmptyTrimmedString.nullable().default(null),
918
+ rewardXp: z.number().int().min(1).max(100).default(12),
919
+ penaltyXp: z.number().int().min(1).max(100).default(8)
920
+ };
921
+ export const createHabitSchema = z.object(habitMutationShape).superRefine((value, context) => {
922
+ if (value.frequency === "weekly" && value.weekDays.length === 0) {
923
+ context.addIssue({
924
+ code: z.ZodIssueCode.custom,
925
+ path: ["weekDays"],
926
+ message: "Select at least one weekday for weekly habits"
927
+ });
928
+ }
929
+ });
930
+ export const updateHabitSchema = z.object({
931
+ title: nonEmptyTrimmedString.optional(),
932
+ description: trimmedString.optional(),
933
+ status: habitStatusSchema.optional(),
934
+ polarity: habitPolaritySchema.optional(),
935
+ frequency: habitFrequencySchema.optional(),
936
+ targetCount: z.number().int().min(1).max(14).optional(),
937
+ weekDays: z.array(z.number().int().min(0).max(6)).max(7).optional(),
938
+ linkedGoalIds: uniqueStringArraySchema.optional(),
939
+ linkedProjectIds: uniqueStringArraySchema.optional(),
940
+ linkedTaskIds: uniqueStringArraySchema.optional(),
941
+ linkedValueIds: uniqueStringArraySchema.optional(),
942
+ linkedPatternIds: uniqueStringArraySchema.optional(),
943
+ linkedBehaviorIds: uniqueStringArraySchema.optional(),
944
+ linkedBeliefIds: uniqueStringArraySchema.optional(),
945
+ linkedModeIds: uniqueStringArraySchema.optional(),
946
+ linkedReportIds: uniqueStringArraySchema.optional(),
947
+ linkedBehaviorId: nonEmptyTrimmedString.nullable().optional(),
948
+ rewardXp: z.number().int().min(1).max(100).optional(),
949
+ penaltyXp: z.number().int().min(1).max(100).optional()
950
+ }).superRefine((value, context) => {
951
+ if (value.frequency === "weekly" && value.weekDays !== undefined && value.weekDays.length === 0) {
952
+ context.addIssue({
953
+ code: z.ZodIssueCode.custom,
954
+ path: ["weekDays"],
955
+ message: "Select at least one weekday for weekly habits"
956
+ });
957
+ }
958
+ });
825
959
  export const updateTaskSchema = z.object({
826
960
  title: nonEmptyTrimmedString.optional(),
827
961
  description: trimmedString.optional(),
@@ -880,6 +1014,11 @@ export const taskRunFinishSchema = z.object({
880
1014
  export const taskRunFocusSchema = z.object({
881
1015
  actor: nonEmptyTrimmedString.optional()
882
1016
  });
1017
+ export const createHabitCheckInSchema = z.object({
1018
+ dateKey: dateOnlySchema.default(new Date().toISOString().slice(0, 10)),
1019
+ status: habitCheckInStatusSchema,
1020
+ note: trimmedString.default("")
1021
+ });
883
1022
  export const updateSettingsSchema = z.object({
884
1023
  profile: z
885
1024
  .object({
@@ -2,7 +2,7 @@
2
2
  "id": "forge-openclaw-plugin",
3
3
  "name": "Forge",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
- "version": "0.2.15",
5
+ "version": "0.2.18",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],