forge-openclaw-plugin 0.2.19 → 0.2.21

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 (82) hide show
  1. package/README.md +133 -2
  2. package/dist/assets/board-_C6oMy5w.js +6 -0
  3. package/dist/assets/{board-8L3uX7_O.js.map → board-_C6oMy5w.js.map} +1 -1
  4. package/dist/assets/index-B4A6TooJ.js +63 -0
  5. package/dist/assets/index-B4A6TooJ.js.map +1 -0
  6. package/dist/assets/index-D6Xs_2mo.css +1 -0
  7. package/dist/assets/{motion-1GAqqi8M.js → motion-D4sZgCHd.js} +2 -2
  8. package/dist/assets/{motion-1GAqqi8M.js.map → motion-D4sZgCHd.js.map} +1 -1
  9. package/dist/assets/{table-DBGlgRjk.js → table-BWzTaky1.js} +2 -2
  10. package/dist/assets/{table-DBGlgRjk.js.map → table-BWzTaky1.js.map} +1 -1
  11. package/dist/assets/{ui-iTluWjC4.js → ui-BzK4azQb.js} +7 -7
  12. package/dist/assets/{ui-iTluWjC4.js.map → ui-BzK4azQb.js.map} +1 -1
  13. package/dist/assets/vendor-DT3pnAKJ.css +1 -0
  14. package/dist/assets/vendor-De38P6YR.js +729 -0
  15. package/dist/assets/vendor-De38P6YR.js.map +1 -0
  16. package/dist/assets/viz-C6hfyqzu.js +34 -0
  17. package/dist/assets/viz-C6hfyqzu.js.map +1 -0
  18. package/dist/index.html +9 -9
  19. package/dist/openclaw/parity.d.ts +1 -1
  20. package/dist/openclaw/parity.js +29 -2
  21. package/dist/openclaw/routes.js +207 -24
  22. package/dist/openclaw/tools.js +324 -35
  23. package/dist/server/app.js +2080 -92
  24. package/dist/server/db.js +3 -0
  25. package/dist/server/health.js +1284 -0
  26. package/dist/server/managers/platform/background-job-manager.js +138 -2
  27. package/dist/server/managers/platform/llm-manager.js +126 -0
  28. package/dist/server/managers/platform/openai-responses-provider.js +773 -0
  29. package/dist/server/managers/runtime.js +6 -1
  30. package/dist/server/openapi.js +718 -0
  31. package/dist/server/preferences-seeds.js +409 -0
  32. package/dist/server/preferences-types.js +368 -0
  33. package/dist/server/psyche-types.js +42 -18
  34. package/dist/server/repositories/activity-events.js +53 -4
  35. package/dist/server/repositories/calendar.js +89 -15
  36. package/dist/server/repositories/collaboration.js +8 -3
  37. package/dist/server/repositories/diagnostic-logs.js +243 -0
  38. package/dist/server/repositories/entity-ownership.js +92 -0
  39. package/dist/server/repositories/goals.js +7 -2
  40. package/dist/server/repositories/habits.js +122 -16
  41. package/dist/server/repositories/notes.js +119 -41
  42. package/dist/server/repositories/preferences.js +1765 -0
  43. package/dist/server/repositories/projects.js +18 -7
  44. package/dist/server/repositories/psyche.js +84 -27
  45. package/dist/server/repositories/rewards.js +112 -4
  46. package/dist/server/repositories/strategies.js +450 -0
  47. package/dist/server/repositories/tags.js +11 -6
  48. package/dist/server/repositories/task-runs.js +10 -2
  49. package/dist/server/repositories/tasks.js +99 -17
  50. package/dist/server/repositories/users.js +417 -0
  51. package/dist/server/repositories/wiki-memory.js +3366 -0
  52. package/dist/server/services/context.js +20 -18
  53. package/dist/server/services/dashboard.js +29 -6
  54. package/dist/server/services/entity-crud.js +21 -3
  55. package/dist/server/services/insights.js +9 -7
  56. package/dist/server/services/projects.js +2 -1
  57. package/dist/server/services/psyche.js +10 -9
  58. package/dist/server/types.js +594 -30
  59. package/openclaw.plugin.json +1 -1
  60. package/package.json +1 -1
  61. package/server/migrations/015_multi_user_and_strategies.sql +244 -0
  62. package/server/migrations/016_health_companion.sql +158 -0
  63. package/server/migrations/016_strategy_contracts_and_user_graph.sql +22 -0
  64. package/server/migrations/017_preferences.sql +131 -0
  65. package/server/migrations/018_preference_catalogs.sql +31 -0
  66. package/server/migrations/019_wiki_memory.sql +255 -0
  67. package/server/migrations/020_wiki_page_hierarchy.sql +11 -0
  68. package/server/migrations/021_hide_evidence_from_wiki_index.sql +3 -0
  69. package/server/migrations/022_wiki_ingest_background.sql +85 -0
  70. package/server/migrations/023_diagnostic_logs.sql +28 -0
  71. package/skills/forge-openclaw/SKILL.md +126 -34
  72. package/skills/forge-openclaw/entity_conversation_playbooks.md +337 -0
  73. package/skills/forge-openclaw/psyche_entity_playbooks.md +404 -0
  74. package/dist/assets/board-8L3uX7_O.js +0 -6
  75. package/dist/assets/index-Cj1IBH_w.js +0 -36
  76. package/dist/assets/index-Cj1IBH_w.js.map +0 -1
  77. package/dist/assets/index-DQT6EbuS.css +0 -1
  78. package/dist/assets/vendor-BvM2F9Dp.js +0 -503
  79. package/dist/assets/vendor-BvM2F9Dp.js.map +0 -1
  80. package/dist/assets/vendor-CRS-psbw.css +0 -1
  81. package/dist/assets/viz-CNeunkfu.js +0 -34
  82. package/dist/assets/viz-CNeunkfu.js.map +0 -1
@@ -311,7 +311,9 @@ export function getRewardLedgerEventById(rewardId) {
311
311
  }
312
312
  export function getTotalXp() {
313
313
  ensureDefaultRewardRules();
314
- const row = getDatabase().prepare(`SELECT COALESCE(SUM(delta_xp), 0) AS total FROM reward_ledger`).get();
314
+ const row = getDatabase()
315
+ .prepare(`SELECT COALESCE(SUM(delta_xp), 0) AS total FROM reward_ledger`)
316
+ .get();
315
317
  return row.total;
316
318
  }
317
319
  export function getWeeklyXp(weekStartIso) {
@@ -410,7 +412,59 @@ export function reverseLatestTaskCompletionReward(task, activity) {
410
412
  taskId: task.id
411
413
  }
412
414
  });
413
- getDatabase().prepare(`UPDATE reward_ledger SET reversed_by_reward_id = ? WHERE id = ?`).run(reversal.id, latest.id);
415
+ getDatabase()
416
+ .prepare(`UPDATE reward_ledger SET reversed_by_reward_id = ? WHERE id = ?`)
417
+ .run(reversal.id, latest.id);
418
+ return reversal;
419
+ }
420
+ export function reverseLatestHabitCheckInReward(habit, dateKey, activity) {
421
+ ensureDefaultRewardRules();
422
+ const reversibleGroup = `habit:${habit.id}:${dateKey}`;
423
+ const latest = getDatabase()
424
+ .prepare(`SELECT
425
+ id, rule_id, event_log_id, entity_type, entity_id, actor, source, delta_xp, reason_title, reason_summary,
426
+ reversible_group, reversed_by_reward_id, metadata_json, created_at
427
+ FROM reward_ledger
428
+ WHERE reversible_group = ?
429
+ AND reversed_by_reward_id IS NULL
430
+ ORDER BY created_at DESC
431
+ LIMIT 1`)
432
+ .get(reversibleGroup);
433
+ if (!latest) {
434
+ return null;
435
+ }
436
+ const reversalEventLog = recordEventLog({
437
+ eventKind: "reward.habit_check_in_reversed",
438
+ entityType: "habit",
439
+ entityId: habit.id,
440
+ actor: activity.actor ?? null,
441
+ source: activity.source,
442
+ metadata: {
443
+ rewardId: latest.id,
444
+ habitId: habit.id,
445
+ dateKey
446
+ }
447
+ });
448
+ const reversal = insertLedgerEvent({
449
+ ruleId: latest.rule_id,
450
+ eventLogId: reversalEventLog.id,
451
+ entityType: latest.entity_type,
452
+ entityId: latest.entity_id,
453
+ actor: activity.actor ?? null,
454
+ source: activity.source,
455
+ deltaXp: -latest.delta_xp,
456
+ reasonTitle: `Habit entry removed: ${habit.title}`,
457
+ reasonSummary: `Habit check-in removed for ${dateKey}.`,
458
+ reversibleGroup: latest.reversible_group,
459
+ metadata: {
460
+ reversedRewardId: latest.id,
461
+ habitId: habit.id,
462
+ dateKey
463
+ }
464
+ });
465
+ getDatabase()
466
+ .prepare(`UPDATE reward_ledger SET reversed_by_reward_id = ? WHERE id = ?`)
467
+ .run(reversal.id, latest.id);
414
468
  return reversal;
415
469
  }
416
470
  export function recordInsightAppliedReward(insightId, entityType, entityId, activity) {
@@ -653,7 +707,9 @@ export function recordWorkAdjustmentReward(input) {
653
707
  actor: input.actor ?? null,
654
708
  source: input.source,
655
709
  deltaXp,
656
- reasonTitle: bucketDelta > 0 ? "Manual work minutes added" : "Manual work minutes removed",
710
+ reasonTitle: bucketDelta > 0
711
+ ? "Manual work minutes added"
712
+ : "Manual work minutes removed",
657
713
  reasonSummary: `${appliedMinutes} manual minute${appliedMinutes === 1 ? "" : "s"} ${direction}, shifting ${Math.abs(bucketDelta)} ${intervalMinutes}-minute reward bucket${Math.abs(bucketDelta) === 1 ? "" : "s"} for ${input.targetTitle}.`,
658
714
  reversibleGroup: `work_adjustment:${entityType}:${input.entityId}:${input.adjustmentId}`,
659
715
  metadata: {
@@ -695,7 +751,8 @@ export function recordSessionEvent(input, activity, now = new Date()) {
695
751
  }, now);
696
752
  const day = sessionEvent.createdAt.slice(0, 10);
697
753
  const currentAmbientXp = getDailyAmbientXp(day);
698
- const active = sessionEvent.metrics.visible === true && sessionEvent.metrics.interacted === true;
754
+ const active = sessionEvent.metrics.visible === true &&
755
+ sessionEvent.metrics.interacted === true;
699
756
  const ruleCode = sessionEvent.eventType === "dwell_120_seconds"
700
757
  ? "session_dwell_120"
701
758
  : sessionEvent.eventType === "scroll_depth_75"
@@ -770,6 +827,57 @@ export function recordHabitCheckInReward(habit, status, dateKey, activity) {
770
827
  }
771
828
  });
772
829
  }
830
+ export function recordHabitGeneratedWorkoutReward(input, activity) {
831
+ ensureDefaultRewardRules();
832
+ if (input.xpReward <= 0) {
833
+ return null;
834
+ }
835
+ const reversibleGroup = `habit_generated_workout:${input.checkInId}`;
836
+ const existing = getDatabase()
837
+ .prepare(`SELECT
838
+ id, rule_id, event_log_id, entity_type, entity_id, actor, source, delta_xp, reason_title, reason_summary,
839
+ reversible_group, reversed_by_reward_id, metadata_json, created_at
840
+ FROM reward_ledger
841
+ WHERE reversible_group = ?
842
+ LIMIT 1`)
843
+ .get(reversibleGroup);
844
+ if (existing) {
845
+ return mapLedger(existing);
846
+ }
847
+ const eventLog = recordEventLog({
848
+ eventKind: "reward.habit_generated_workout",
849
+ entityType: "habit",
850
+ entityId: input.habitId,
851
+ actor: activity.actor ?? null,
852
+ source: activity.source,
853
+ metadata: {
854
+ habitId: input.habitId,
855
+ checkInId: input.checkInId,
856
+ workoutId: input.workoutId,
857
+ workoutType: input.workoutType,
858
+ xpReward: input.xpReward
859
+ }
860
+ });
861
+ return insertLedgerEvent({
862
+ ruleId: null,
863
+ eventLogId: eventLog.id,
864
+ entityType: "habit",
865
+ entityId: input.habitId,
866
+ actor: activity.actor ?? null,
867
+ source: activity.source,
868
+ deltaXp: input.xpReward,
869
+ reasonTitle: `Generated workout: ${input.habitTitle}`,
870
+ reasonSummary: `Created a ${input.workoutType} session from a completed habit.`,
871
+ reversibleGroup,
872
+ metadata: {
873
+ habitId: input.habitId,
874
+ checkInId: input.checkInId,
875
+ workoutId: input.workoutId,
876
+ workoutType: input.workoutType,
877
+ rewardCategory: "habit_generated_workout"
878
+ }
879
+ });
880
+ }
773
881
  export function recordWeeklyReviewCompletionReward(input, activity) {
774
882
  ensureDefaultRewardRules();
775
883
  const rule = getRuleByCode("weekly_review_completed");
@@ -0,0 +1,450 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDatabase, runInTransaction } from "../db.js";
3
+ import { HttpError } from "../errors.js";
4
+ import { decorateOwnedEntity, filterOwnedEntities, inferFirstOwnedUserId, setEntityOwner } from "./entity-ownership.js";
5
+ import { getUserById, resolveUserForMutation } from "./users.js";
6
+ import { getBehaviorById, getBehaviorPatternById, getBeliefEntryById, getEmotionDefinitionById, getEventTypeById, getModeGuideSessionById, getModeProfileById, getPsycheValueById, getTriggerReportById } from "./psyche.js";
7
+ import { getCalendarEventById, getTaskTimeboxById, getWorkBlockTemplateById } from "./calendar.js";
8
+ import { getGoalById } from "./goals.js";
9
+ import { getHabitById } from "./habits.js";
10
+ import { getInsightById } from "./collaboration.js";
11
+ import { getNoteById } from "./notes.js";
12
+ import { getProjectById } from "./projects.js";
13
+ import { getTagById } from "./tags.js";
14
+ import { getTaskById, listTasks } from "./tasks.js";
15
+ import { getProjectSummary } from "../services/projects.js";
16
+ import { createStrategySchema, strategySchema, updateStrategySchema } from "../types.js";
17
+ function statusProgress(status) {
18
+ switch (status) {
19
+ case "done":
20
+ case "completed":
21
+ case "reviewed":
22
+ case "integrated":
23
+ return 1;
24
+ case "in_progress":
25
+ case "active":
26
+ return 0.66;
27
+ case "focus":
28
+ return 0.5;
29
+ case "blocked":
30
+ case "paused":
31
+ return 0.25;
32
+ default:
33
+ return 0;
34
+ }
35
+ }
36
+ function goalProgress(goalId) {
37
+ const tasks = listTasks({ goalId });
38
+ if (tasks.length === 0) {
39
+ const goal = getGoalById(goalId);
40
+ return goal?.status === "completed" ? 1 : 0;
41
+ }
42
+ const completed = tasks.filter((task) => task.status === "done").length;
43
+ return completed / tasks.length;
44
+ }
45
+ function resolveLinkedEntity(entityType, entityId) {
46
+ switch (entityType) {
47
+ case "goal":
48
+ return getGoalById(entityId);
49
+ case "project":
50
+ return getProjectById(entityId);
51
+ case "task":
52
+ return getTaskById(entityId);
53
+ case "strategy":
54
+ return getStrategyById(entityId);
55
+ case "habit":
56
+ return getHabitById(entityId);
57
+ case "tag":
58
+ return getTagById(entityId);
59
+ case "note":
60
+ return getNoteById(entityId);
61
+ case "insight":
62
+ return getInsightById(entityId);
63
+ case "calendar_event":
64
+ return getCalendarEventById(entityId);
65
+ case "work_block_template":
66
+ return getWorkBlockTemplateById(entityId);
67
+ case "task_timebox":
68
+ return getTaskTimeboxById(entityId);
69
+ case "psyche_value":
70
+ return getPsycheValueById(entityId);
71
+ case "behavior_pattern":
72
+ return getBehaviorPatternById(entityId);
73
+ case "behavior":
74
+ return getBehaviorById(entityId);
75
+ case "belief_entry":
76
+ return getBeliefEntryById(entityId);
77
+ case "mode_profile":
78
+ return getModeProfileById(entityId);
79
+ case "mode_guide_session":
80
+ return getModeGuideSessionById(entityId);
81
+ case "event_type":
82
+ return getEventTypeById(entityId);
83
+ case "emotion_definition":
84
+ return getEmotionDefinitionById(entityId);
85
+ case "trigger_report":
86
+ return getTriggerReportById(entityId);
87
+ default:
88
+ return null;
89
+ }
90
+ }
91
+ function assertStrategyRelations(input) {
92
+ for (const goalId of input.targetGoalIds) {
93
+ if (!getGoalById(goalId)) {
94
+ throw new Error(`Goal ${goalId} does not exist`);
95
+ }
96
+ }
97
+ for (const projectId of input.targetProjectIds) {
98
+ if (!getProjectById(projectId)) {
99
+ throw new Error(`Project ${projectId} does not exist`);
100
+ }
101
+ }
102
+ for (const linked of input.linkedEntities) {
103
+ if (!resolveLinkedEntity(linked.entityType, linked.entityId)) {
104
+ throw new Error(`${linked.entityType} ${linked.entityId} does not exist`);
105
+ }
106
+ }
107
+ for (const node of input.graph.nodes) {
108
+ if (node.entityType === "project" && !getProjectById(node.entityId)) {
109
+ throw new Error(`Project ${node.entityId} does not exist`);
110
+ }
111
+ if (node.entityType === "task" && !getTaskById(node.entityId)) {
112
+ throw new Error(`Task ${node.entityId} does not exist`);
113
+ }
114
+ }
115
+ }
116
+ function parseJsonArray(value) {
117
+ return JSON.parse(value);
118
+ }
119
+ function nodeProgress(node) {
120
+ if (node.entityType === "project") {
121
+ return (getProjectSummary(node.entityId)?.progress ?? 0) / 100;
122
+ }
123
+ return statusProgress(getTaskById(node.entityId)?.status ?? "backlog");
124
+ }
125
+ function buildStrategyMetrics(graph, targetGoalIds, targetProjectIds) {
126
+ const nodeProgressById = new Map(graph.nodes.map((node) => [node.id, nodeProgress(node)]));
127
+ const incoming = new Map();
128
+ for (const node of graph.nodes) {
129
+ incoming.set(node.id, []);
130
+ }
131
+ for (const edge of graph.edges) {
132
+ incoming.get(edge.to)?.push(edge.from);
133
+ }
134
+ const completedNodeIds = graph.nodes
135
+ .filter((node) => (nodeProgressById.get(node.id) ?? 0) >= 1)
136
+ .map((node) => node.id);
137
+ const startedNodeIds = graph.nodes
138
+ .filter((node) => (nodeProgressById.get(node.id) ?? 0) > 0)
139
+ .map((node) => node.id);
140
+ const blockedNodeIds = graph.nodes
141
+ .filter((node) => {
142
+ if (node.entityType === "project") {
143
+ return getProjectById(node.entityId)?.status === "paused";
144
+ }
145
+ return getTaskById(node.entityId)?.status === "blocked";
146
+ })
147
+ .map((node) => node.id);
148
+ const outOfOrderNodeIds = graph.nodes
149
+ .filter((node) => {
150
+ const progress = nodeProgressById.get(node.id) ?? 0;
151
+ if (progress <= 0) {
152
+ return false;
153
+ }
154
+ const prerequisites = incoming.get(node.id) ?? [];
155
+ return prerequisites.some((dependencyId) => (nodeProgressById.get(dependencyId) ?? 0) < 1);
156
+ })
157
+ .map((node) => node.id);
158
+ const activeNodeIds = graph.nodes
159
+ .filter((node) => {
160
+ const progress = nodeProgressById.get(node.id) ?? 0;
161
+ if (progress >= 1) {
162
+ return false;
163
+ }
164
+ const prerequisites = incoming.get(node.id) ?? [];
165
+ return prerequisites.every((dependencyId) => (nodeProgressById.get(dependencyId) ?? 0) >= 1);
166
+ })
167
+ .map((node) => node.id);
168
+ const goalScores = targetGoalIds.map((goalId) => goalProgress(goalId));
169
+ const projectScores = targetProjectIds.map((projectId) => (getProjectSummary(projectId)?.progress ?? 0) / 100);
170
+ const targetScores = [...goalScores, ...projectScores];
171
+ const nodeAverage = graph.nodes.length === 0
172
+ ? 0
173
+ : graph.nodes.reduce((sum, node) => sum + (nodeProgressById.get(node.id) ?? 0), 0) / graph.nodes.length;
174
+ const targetAverage = targetScores.length === 0
175
+ ? nodeAverage
176
+ : targetScores.reduce((sum, value) => sum + value, 0) /
177
+ targetScores.length;
178
+ const graphProjectIds = new Set(graph.nodes
179
+ .filter((node) => node.entityType === "project")
180
+ .map((node) => node.entityId));
181
+ const graphTaskIds = new Set(graph.nodes
182
+ .filter((node) => node.entityType === "task")
183
+ .map((node) => node.entityId));
184
+ const offPlanEntityKeys = new Set();
185
+ const offPlanActiveEntityKeys = new Set();
186
+ const offPlanCompletedEntityKeys = new Set();
187
+ const markOffPlanTask = (taskId) => {
188
+ const task = getTaskById(taskId);
189
+ if (!task) {
190
+ return;
191
+ }
192
+ const entityKey = `task:${task.id}`;
193
+ offPlanEntityKeys.add(entityKey);
194
+ if (task.status === "done") {
195
+ offPlanCompletedEntityKeys.add(entityKey);
196
+ return;
197
+ }
198
+ if (["focus", "in_progress", "blocked"].includes(task.status)) {
199
+ offPlanActiveEntityKeys.add(entityKey);
200
+ }
201
+ };
202
+ for (const projectId of targetProjectIds) {
203
+ const project = getProjectById(projectId);
204
+ if (project &&
205
+ !graphProjectIds.has(project.id) &&
206
+ project.status !== "completed") {
207
+ const entityKey = `project:${project.id}`;
208
+ offPlanEntityKeys.add(entityKey);
209
+ offPlanActiveEntityKeys.add(entityKey);
210
+ }
211
+ for (const task of listTasks({ projectId })) {
212
+ if (!graphTaskIds.has(task.id) &&
213
+ ["focus", "in_progress", "done", "blocked"].includes(task.status)) {
214
+ markOffPlanTask(task.id);
215
+ }
216
+ }
217
+ }
218
+ for (const goalId of targetGoalIds) {
219
+ for (const task of listTasks({ goalId })) {
220
+ if (!graphTaskIds.has(task.id) &&
221
+ ["focus", "in_progress", "done", "blocked"].includes(task.status)) {
222
+ markOffPlanTask(task.id);
223
+ }
224
+ }
225
+ }
226
+ const totalNodes = Math.max(1, graph.nodes.length);
227
+ const offPlanEntityCount = offPlanEntityKeys.size;
228
+ const offPlanActiveEntityCount = offPlanActiveEntityKeys.size;
229
+ const offPlanCompletedEntityCount = offPlanCompletedEntityKeys.size;
230
+ const planCoverageScore = Math.max(0, Math.min(100, Math.round(nodeAverage * 100)));
231
+ const sequencingScore = Math.max(0, Math.min(100, Math.round(100 - (outOfOrderNodeIds.length / totalNodes) * 100)));
232
+ const scopeDisciplineScore = Math.max(0, Math.min(100, Math.round(100 - (offPlanEntityCount / totalNodes) * 100)));
233
+ const blockedRatio = blockedNodeIds.length / totalNodes;
234
+ const qualityScore = Math.max(0, Math.min(100, Math.round(Math.max(0, Math.min(1, targetAverage * 0.8 + (1 - blockedRatio) * 0.2)) * 100)));
235
+ const targetProgressScore = Math.max(0, Math.min(100, Math.round(targetAverage * 100)));
236
+ const alignmentScore = Math.max(0, Math.min(100, Math.round(planCoverageScore * 0.35 +
237
+ sequencingScore * 0.3 +
238
+ scopeDisciplineScore * 0.2 +
239
+ qualityScore * 0.15)));
240
+ return {
241
+ alignmentScore,
242
+ planCoverageScore,
243
+ sequencingScore,
244
+ scopeDisciplineScore,
245
+ qualityScore,
246
+ targetProgressScore,
247
+ completedNodeCount: completedNodeIds.length,
248
+ startedNodeCount: startedNodeIds.length,
249
+ readyNodeCount: activeNodeIds.length,
250
+ totalNodeCount: totalNodes,
251
+ completedTargetCount: targetScores.filter((score) => score >= 1).length,
252
+ totalTargetCount: targetScores.length,
253
+ offPlanEntityCount,
254
+ offPlanActiveEntityCount,
255
+ offPlanCompletedEntityCount,
256
+ activeNodeIds: activeNodeIds.slice(0, 8),
257
+ nextNodeIds: activeNodeIds.slice(0, 5),
258
+ blockedNodeIds,
259
+ outOfOrderNodeIds
260
+ };
261
+ }
262
+ function assertStrategyContractReady(input) {
263
+ if (input.graph.nodes.length === 0) {
264
+ throw new HttpError(400, "strategy_contract_invalid", "A locked strategy needs at least one project or task node in its graph.", { fields: ["graph.nodes"] });
265
+ }
266
+ if (input.targetGoalIds.length === 0 && input.targetProjectIds.length === 0) {
267
+ throw new HttpError(400, "strategy_contract_invalid", "A locked strategy must target at least one goal or project.", { fields: ["targetGoalIds", "targetProjectIds"] });
268
+ }
269
+ if (input.overview.trim().length === 0 &&
270
+ input.endStateDescription.trim().length === 0) {
271
+ throw new HttpError(400, "strategy_contract_invalid", "A locked strategy needs an overview or end-state description so the contract is explicit.", { fields: ["overview", "endStateDescription"] });
272
+ }
273
+ }
274
+ function mapStrategy(row) {
275
+ const graph = JSON.parse(row.graph_json);
276
+ return strategySchema.parse(decorateOwnedEntity("strategy", {
277
+ id: row.id,
278
+ title: row.title,
279
+ overview: row.overview,
280
+ endStateDescription: row.end_state_description,
281
+ status: row.status,
282
+ targetGoalIds: parseJsonArray(row.target_goal_ids_json),
283
+ targetProjectIds: parseJsonArray(row.target_project_ids_json),
284
+ linkedEntities: parseJsonArray(row.linked_entities_json),
285
+ graph,
286
+ metrics: buildStrategyMetrics(graph, parseJsonArray(row.target_goal_ids_json), parseJsonArray(row.target_project_ids_json)),
287
+ isLocked: row.is_locked === 1,
288
+ lockedAt: row.locked_at,
289
+ lockedByUserId: row.locked_by_user_id,
290
+ lockedByUser: row.locked_by_user_id
291
+ ? (getUserById(row.locked_by_user_id) ?? null)
292
+ : null,
293
+ createdAt: row.created_at,
294
+ updatedAt: row.updated_at
295
+ }));
296
+ }
297
+ export function listStrategies(filters = {}) {
298
+ const whereClauses = [];
299
+ const params = [];
300
+ if (filters.status) {
301
+ whereClauses.push("status = ?");
302
+ params.push(filters.status);
303
+ }
304
+ const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
305
+ const limitSql = filters.limit ? "LIMIT ?" : "";
306
+ if (filters.limit) {
307
+ params.push(filters.limit);
308
+ }
309
+ const rows = getDatabase()
310
+ .prepare(`SELECT id, title, overview, end_state_description, status, target_goal_ids_json, target_project_ids_json,
311
+ linked_entities_json, graph_json, is_locked, locked_at, locked_by_user_id, created_at, updated_at
312
+ FROM strategies
313
+ ${whereSql}
314
+ ORDER BY updated_at DESC
315
+ ${limitSql}`)
316
+ .all(...params);
317
+ return filterOwnedEntities("strategy", rows.map(mapStrategy), filters.userIds);
318
+ }
319
+ export function getStrategyById(strategyId) {
320
+ const row = getDatabase()
321
+ .prepare(`SELECT id, title, overview, end_state_description, status, target_goal_ids_json, target_project_ids_json,
322
+ linked_entities_json, graph_json, is_locked, locked_at, locked_by_user_id, created_at, updated_at
323
+ FROM strategies
324
+ WHERE id = ?`)
325
+ .get(strategyId);
326
+ return row ? mapStrategy(row) : undefined;
327
+ }
328
+ export function createStrategy(input) {
329
+ return runInTransaction(() => {
330
+ const parsed = createStrategySchema.parse(input);
331
+ assertStrategyRelations(parsed);
332
+ if (parsed.isLocked) {
333
+ assertStrategyContractReady(parsed);
334
+ }
335
+ const now = new Date().toISOString();
336
+ const id = `strategy_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
337
+ const inferredOwnerUserId = parsed.userId ??
338
+ inferFirstOwnedUserId([
339
+ ...parsed.targetProjectIds.map((entityId) => ({
340
+ entityType: "project",
341
+ entityId
342
+ })),
343
+ ...parsed.targetGoalIds.map((entityId) => ({
344
+ entityType: "goal",
345
+ entityId
346
+ })),
347
+ ...parsed.graph.nodes.map((node) => ({
348
+ entityType: node.entityType,
349
+ entityId: node.entityId
350
+ })),
351
+ ...parsed.linkedEntities.map((entity) => ({
352
+ entityType: entity.entityType,
353
+ entityId: entity.entityId
354
+ }))
355
+ ]);
356
+ const ownerUser = resolveUserForMutation(inferredOwnerUserId);
357
+ const lockedByUserId = parsed.isLocked
358
+ ? resolveUserForMutation(parsed.lockedByUserId ?? ownerUser.id).id
359
+ : null;
360
+ getDatabase()
361
+ .prepare(`INSERT INTO strategies (
362
+ id, title, overview, end_state_description, status, target_goal_ids_json, target_project_ids_json,
363
+ linked_entities_json, graph_json, is_locked, locked_at, locked_by_user_id, created_at, updated_at
364
+ )
365
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
366
+ .run(id, parsed.title, parsed.overview, parsed.endStateDescription, parsed.status, JSON.stringify(parsed.targetGoalIds), JSON.stringify(parsed.targetProjectIds), JSON.stringify(parsed.linkedEntities), JSON.stringify(parsed.graph), parsed.isLocked ? 1 : 0, parsed.isLocked ? now : null, lockedByUserId, now, now);
367
+ setEntityOwner("strategy", id, ownerUser.id);
368
+ return getStrategyById(id);
369
+ });
370
+ }
371
+ export function updateStrategy(strategyId, patch) {
372
+ const current = getStrategyById(strategyId);
373
+ if (!current) {
374
+ return undefined;
375
+ }
376
+ return runInTransaction(() => {
377
+ const parsed = updateStrategySchema.parse(patch);
378
+ const changesCoreStrategyShape = parsed.title !== undefined ||
379
+ parsed.overview !== undefined ||
380
+ parsed.endStateDescription !== undefined ||
381
+ parsed.targetGoalIds !== undefined ||
382
+ parsed.targetProjectIds !== undefined ||
383
+ parsed.linkedEntities !== undefined ||
384
+ parsed.graph !== undefined ||
385
+ parsed.userId !== undefined;
386
+ if (current.isLocked &&
387
+ parsed.isLocked !== false &&
388
+ changesCoreStrategyShape) {
389
+ throw new Error("Strategy is locked as a contract. Unlock it before changing the plan, targets, links, or owner.");
390
+ }
391
+ const next = {
392
+ title: parsed.title ?? current.title,
393
+ overview: parsed.overview ?? current.overview,
394
+ endStateDescription: parsed.endStateDescription ?? current.endStateDescription,
395
+ status: parsed.status ?? current.status,
396
+ targetGoalIds: parsed.targetGoalIds ?? current.targetGoalIds,
397
+ targetProjectIds: parsed.targetProjectIds ?? current.targetProjectIds,
398
+ linkedEntities: parsed.linkedEntities ?? current.linkedEntities,
399
+ graph: parsed.graph ?? current.graph,
400
+ isLocked: parsed.isLocked ?? current.isLocked,
401
+ lockedAt: parsed.isLocked === false
402
+ ? null
403
+ : parsed.isLocked === true && !current.isLocked
404
+ ? new Date().toISOString()
405
+ : current.lockedAt,
406
+ lockedByUserId: parsed.isLocked === false
407
+ ? null
408
+ : parsed.isLocked === true
409
+ ? resolveUserForMutation(parsed.lockedByUserId ??
410
+ current.lockedByUserId ??
411
+ parsed.userId ??
412
+ current.userId ??
413
+ inferFirstOwnedUserId([
414
+ ...current.targetProjectIds.map((entityId) => ({
415
+ entityType: "project",
416
+ entityId
417
+ })),
418
+ ...current.targetGoalIds.map((entityId) => ({
419
+ entityType: "goal",
420
+ entityId
421
+ }))
422
+ ])).id
423
+ : current.lockedByUserId,
424
+ updatedAt: new Date().toISOString()
425
+ };
426
+ assertStrategyRelations(next);
427
+ if (next.isLocked) {
428
+ assertStrategyContractReady(next);
429
+ }
430
+ getDatabase()
431
+ .prepare(`UPDATE strategies
432
+ SET title = ?, overview = ?, end_state_description = ?, status = ?, target_goal_ids_json = ?,
433
+ target_project_ids_json = ?, linked_entities_json = ?, graph_json = ?, is_locked = ?, locked_at = ?,
434
+ locked_by_user_id = ?, updated_at = ?
435
+ WHERE id = ?`)
436
+ .run(next.title, next.overview, next.endStateDescription, next.status, JSON.stringify(next.targetGoalIds), JSON.stringify(next.targetProjectIds), JSON.stringify(next.linkedEntities), JSON.stringify(next.graph), next.isLocked ? 1 : 0, next.lockedAt, next.lockedByUserId, next.updatedAt, strategyId);
437
+ if (parsed.userId !== undefined) {
438
+ setEntityOwner("strategy", strategyId, parsed.userId);
439
+ }
440
+ return getStrategyById(strategyId);
441
+ });
442
+ }
443
+ export function deleteStrategy(strategyId) {
444
+ const strategy = getStrategyById(strategyId);
445
+ if (!strategy) {
446
+ return undefined;
447
+ }
448
+ getDatabase().prepare(`DELETE FROM strategies WHERE id = ?`).run(strategyId);
449
+ return strategy;
450
+ }
@@ -1,17 +1,18 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getDatabase } from "../db.js";
3
3
  import { HttpError } from "../errors.js";
4
+ import { decorateOwnedEntity, setEntityOwner } from "./entity-ownership.js";
4
5
  import { filterDeletedEntities, isEntityDeleted } from "./deleted-entities.js";
5
6
  import { recordActivityEvent } from "./activity-events.js";
6
7
  import { tagSchema, updateTagSchema } from "../types.js";
7
8
  function mapTag(row) {
8
- return tagSchema.parse({
9
- id: row.id,
10
- name: row.name,
9
+ return tagSchema.parse(decorateOwnedEntity("tag", {
10
+ id: String(row.id ?? ""),
11
+ name: String(row.name ?? ""),
11
12
  kind: row.kind,
12
- color: row.color,
13
- description: row.description
14
- });
13
+ color: String(row.color ?? ""),
14
+ description: String(row.description ?? "")
15
+ }));
15
16
  }
16
17
  export function listTags() {
17
18
  const rows = getDatabase()
@@ -56,6 +57,7 @@ export function createTag(input, activity) {
56
57
  .prepare(`INSERT INTO tags (id, name, kind, color, description, created_at)
57
58
  VALUES (?, ?, ?, ?, ?, ?)`)
58
59
  .run(tag.id, tag.name, tag.kind, tag.color, tag.description, now);
60
+ setEntityOwner("tag", tag.id, input.userId);
59
61
  if (activity) {
60
62
  recordActivityEvent({
61
63
  entityType: "tag",
@@ -101,6 +103,9 @@ export function updateTag(tagId, input, activity) {
101
103
  SET name = ?, kind = ?, color = ?, description = ?
102
104
  WHERE id = ?`)
103
105
  .run(tag.name, tag.kind, tag.color, tag.description, tagId);
106
+ if (parsed.userId !== undefined) {
107
+ setEntityOwner("tag", tagId, parsed.userId);
108
+ }
104
109
  if (activity) {
105
110
  recordActivityEvent({
106
111
  entityType: "tag",
@@ -55,6 +55,7 @@ function readExecutionConfig() {
55
55
  }
56
56
  function mapTaskRun(row, now = new Date(), cached = computeWorkTime(now)) {
57
57
  const metric = cached.runMetrics.get(row.id);
58
+ const task = getTaskById(row.task_id);
58
59
  return taskRunSchema.parse({
59
60
  id: row.id,
60
61
  taskId: row.task_id,
@@ -77,7 +78,9 @@ function mapTaskRun(row, now = new Date(), cached = computeWorkTime(now)) {
77
78
  releasedAt: row.released_at,
78
79
  timedOutAt: row.timed_out_at,
79
80
  overrideReason: row.override_reason ?? null,
80
- updatedAt: row.updated_at
81
+ updatedAt: row.updated_at,
82
+ userId: task?.userId ?? null,
83
+ user: task?.user ?? null
81
84
  });
82
85
  }
83
86
  function getTaskRunRowById(taskRunId) {
@@ -284,7 +287,12 @@ export function listTaskRuns(filters = {}, now = new Date()) {
284
287
  ${limitSql}`)
285
288
  .all(...params);
286
289
  const cached = computeWorkTime(now);
287
- return rows.map((row) => mapTaskRun(row, now, cached));
290
+ const runs = rows.map((row) => mapTaskRun(row, now, cached));
291
+ if (!filters.userIds || filters.userIds.length === 0) {
292
+ return runs;
293
+ }
294
+ const allowed = new Set(filters.userIds);
295
+ return runs.filter((run) => run.userId !== null && allowed.has(run.userId));
288
296
  });
289
297
  }
290
298
  export function claimTaskRun(taskId, input, now = new Date(), activity = { source: "ui" }) {