forge-openclaw-plugin 0.2.18 → 0.2.19

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 (56) hide show
  1. package/README.md +36 -4
  2. package/dist/assets/{board-2KevHCI0.js → board-8L3uX7_O.js} +2 -2
  3. package/dist/assets/{board-2KevHCI0.js.map → board-8L3uX7_O.js.map} +1 -1
  4. package/dist/assets/index-Cj1IBH_w.js +36 -0
  5. package/dist/assets/index-Cj1IBH_w.js.map +1 -0
  6. package/dist/assets/index-DQT6EbuS.css +1 -0
  7. package/dist/assets/{motion-q19HPmWs.js → motion-1GAqqi8M.js} +2 -2
  8. package/dist/assets/{motion-q19HPmWs.js.map → motion-1GAqqi8M.js.map} +1 -1
  9. package/dist/assets/{table-BDMHBY4a.js → table-DBGlgRjk.js} +2 -2
  10. package/dist/assets/{table-BDMHBY4a.js.map → table-DBGlgRjk.js.map} +1 -1
  11. package/dist/assets/{ui-CQ_AsFs8.js → ui-iTluWjC4.js} +2 -2
  12. package/dist/assets/{ui-CQ_AsFs8.js.map → ui-iTluWjC4.js.map} +1 -1
  13. package/dist/assets/{vendor-5HifrnRK.js → vendor-BvM2F9Dp.js} +139 -84
  14. package/dist/assets/vendor-BvM2F9Dp.js.map +1 -0
  15. package/dist/assets/{viz-CQzkRnTu.js → viz-CNeunkfu.js} +2 -2
  16. package/dist/assets/{viz-CQzkRnTu.js.map → viz-CNeunkfu.js.map} +1 -1
  17. package/dist/index.html +8 -8
  18. package/dist/openclaw/parity.js +1 -0
  19. package/dist/openclaw/routes.js +7 -0
  20. package/dist/openclaw/tools.js +183 -16
  21. package/dist/server/app.js +2509 -263
  22. package/dist/server/managers/platform/secrets-manager.js +44 -1
  23. package/dist/server/managers/runtime.js +3 -1
  24. package/dist/server/openapi.js +2037 -172
  25. package/dist/server/repositories/calendar.js +1101 -0
  26. package/dist/server/repositories/deleted-entities.js +10 -2
  27. package/dist/server/repositories/notes.js +161 -28
  28. package/dist/server/repositories/projects.js +45 -13
  29. package/dist/server/repositories/rewards.js +114 -6
  30. package/dist/server/repositories/settings.js +47 -5
  31. package/dist/server/repositories/task-runs.js +46 -10
  32. package/dist/server/repositories/tasks.js +25 -9
  33. package/dist/server/repositories/weekly-reviews.js +109 -0
  34. package/dist/server/repositories/work-adjustments.js +105 -0
  35. package/dist/server/services/calendar-runtime.js +1301 -0
  36. package/dist/server/services/entity-crud.js +94 -3
  37. package/dist/server/services/projects.js +32 -8
  38. package/dist/server/services/reviews.js +15 -1
  39. package/dist/server/services/work-time.js +27 -0
  40. package/dist/server/types.js +934 -49
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +1 -1
  43. package/server/migrations/006_work_adjustments.sql +14 -0
  44. package/server/migrations/007_weekly_review_closures.sql +17 -0
  45. package/server/migrations/008_calendar_execution.sql +147 -0
  46. package/server/migrations/009_true_calendar_events.sql +195 -0
  47. package/server/migrations/010_calendar_selection_state.sql +6 -0
  48. package/server/migrations/011_calendar_timezone_backfill.sql +11 -0
  49. package/server/migrations/012_work_block_ranges.sql +7 -0
  50. package/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
  51. package/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
  52. package/skills/forge-openclaw/SKILL.md +117 -11
  53. package/dist/assets/index-CDYW4WDH.js +0 -36
  54. package/dist/assets/index-CDYW4WDH.js.map +0 -1
  55. package/dist/assets/index-yroQr6YZ.css +0 -1
  56. package/dist/assets/vendor-5HifrnRK.js.map +0 -1
@@ -12,6 +12,18 @@ function toInt(value) {
12
12
  function hashToken(token) {
13
13
  return createHash("sha256").update(token).digest("hex");
14
14
  }
15
+ function defaultMicrosoftRedirectUri() {
16
+ const port = process.env.PORT?.trim() || "4317";
17
+ return `http://127.0.0.1:${port}/api/v1/calendar/oauth/microsoft/callback`;
18
+ }
19
+ function normalizeMicrosoftTenantId(value) {
20
+ const trimmed = value?.trim();
21
+ return trimmed && trimmed.length > 0 ? trimmed : "common";
22
+ }
23
+ function normalizeMicrosoftRedirectUri(value) {
24
+ const trimmed = value?.trim();
25
+ return trimmed && trimmed.length > 0 ? trimmed : defaultMicrosoftRedirectUri();
26
+ }
15
27
  function buildTokenSecret() {
16
28
  return `fg_live_${randomBytes(18).toString("hex")}`;
17
29
  }
@@ -108,7 +120,7 @@ function readSettingsRow() {
108
120
  .prepare(`SELECT
109
121
  operator_name, operator_email, operator_title, theme_preference, locale_preference,
110
122
  goal_drift_alerts, daily_quest_reminders, achievement_celebrations, max_active_tasks, time_accounting_mode,
111
- integrity_score, last_audit_at, psyche_auth_required, created_at, updated_at
123
+ integrity_score, last_audit_at, psyche_auth_required, microsoft_client_id, microsoft_tenant_id, microsoft_redirect_uri, created_at, updated_at
112
124
  FROM app_settings
113
125
  WHERE id = 1`)
114
126
  .get();
@@ -166,6 +178,9 @@ export function isPsycheAuthRequired() {
166
178
  }
167
179
  export function getSettings() {
168
180
  const row = readSettingsRow();
181
+ const microsoftClientId = row.microsoft_client_id?.trim() ?? "";
182
+ const microsoftTenantId = normalizeMicrosoftTenantId(row.microsoft_tenant_id);
183
+ const microsoftRedirectUri = normalizeMicrosoftRedirectUri(row.microsoft_redirect_uri);
169
184
  return settingsPayloadSchema.parse({
170
185
  profile: {
171
186
  operatorName: row.operator_name,
@@ -191,6 +206,21 @@ export function getSettings() {
191
206
  tokenCount: listAgentTokens().filter((token) => token.status === "active").length,
192
207
  psycheAuthRequired: boolFromInt(row.psyche_auth_required)
193
208
  },
209
+ calendarProviders: {
210
+ microsoft: {
211
+ clientId: microsoftClientId,
212
+ tenantId: microsoftTenantId,
213
+ redirectUri: microsoftRedirectUri,
214
+ usesClientSecret: false,
215
+ readOnly: true,
216
+ authMode: "public_client_pkce",
217
+ isConfigured: microsoftClientId.length > 0,
218
+ isReadyForSignIn: microsoftClientId.length > 0,
219
+ setupMessage: microsoftClientId.length > 0
220
+ ? "Microsoft local sign-in is configured. Test it if you want, then continue to the guided sign-in flow."
221
+ : "Save the Microsoft client ID and the Forge callback redirect URI here before you try to sign in."
222
+ }
223
+ },
194
224
  agents: listAgentIdentities(),
195
225
  agentTokens: listAgentTokens()
196
226
  });
@@ -217,15 +247,25 @@ export function updateSettings(input, activity) {
217
247
  },
218
248
  themePreference: parsed.themePreference ?? current.themePreference,
219
249
  localePreference: parsed.localePreference ?? current.localePreference,
220
- psycheAuthRequired: parsed.security?.psycheAuthRequired ?? current.security.psycheAuthRequired
250
+ psycheAuthRequired: parsed.security?.psycheAuthRequired ?? current.security.psycheAuthRequired,
251
+ calendarProviders: {
252
+ microsoft: {
253
+ clientId: parsed.calendarProviders?.microsoft?.clientId?.trim() ??
254
+ current.calendarProviders.microsoft.clientId,
255
+ tenantId: normalizeMicrosoftTenantId(parsed.calendarProviders?.microsoft?.tenantId ??
256
+ current.calendarProviders.microsoft.tenantId),
257
+ redirectUri: normalizeMicrosoftRedirectUri(parsed.calendarProviders?.microsoft?.redirectUri ??
258
+ current.calendarProviders.microsoft.redirectUri)
259
+ }
260
+ }
221
261
  };
222
262
  getDatabase()
223
263
  .prepare(`UPDATE app_settings
224
264
  SET operator_name = ?, operator_email = ?, operator_title = ?, theme_preference = ?, locale_preference = ?,
225
265
  goal_drift_alerts = ?, daily_quest_reminders = ?, achievement_celebrations = ?, max_active_tasks = ?, time_accounting_mode = ?,
226
- psyche_auth_required = ?, updated_at = ?
266
+ psyche_auth_required = ?, microsoft_client_id = ?, microsoft_tenant_id = ?, microsoft_redirect_uri = ?, updated_at = ?
227
267
  WHERE id = 1`)
228
- .run(next.profile.operatorName, next.profile.operatorEmail, next.profile.operatorTitle, next.themePreference, next.localePreference, toInt(next.notifications.goalDriftAlerts), toInt(next.notifications.dailyQuestReminders), toInt(next.notifications.achievementCelebrations), next.execution.maxActiveTasks, next.execution.timeAccountingMode, toInt(next.psycheAuthRequired), now);
268
+ .run(next.profile.operatorName, next.profile.operatorEmail, next.profile.operatorTitle, next.themePreference, next.localePreference, toInt(next.notifications.goalDriftAlerts), toInt(next.notifications.dailyQuestReminders), toInt(next.notifications.achievementCelebrations), next.execution.maxActiveTasks, next.execution.timeAccountingMode, toInt(next.psycheAuthRequired), next.calendarProviders.microsoft.clientId, next.calendarProviders.microsoft.tenantId, next.calendarProviders.microsoft.redirectUri, now);
229
269
  if (activity) {
230
270
  recordActivityEvent({
231
271
  entityType: "system",
@@ -241,7 +281,9 @@ export function updateSettings(input, activity) {
241
281
  goalDriftAlerts: next.notifications.goalDriftAlerts,
242
282
  dailyQuestReminders: next.notifications.dailyQuestReminders,
243
283
  maxActiveTasks: next.execution.maxActiveTasks,
244
- timeAccountingMode: next.execution.timeAccountingMode
284
+ timeAccountingMode: next.execution.timeAccountingMode,
285
+ microsoftConfigured: next.calendarProviders.microsoft.clientId.trim().length > 0,
286
+ microsoftTenantId: next.calendarProviders.microsoft.tenantId
245
287
  }
246
288
  });
247
289
  }
@@ -3,6 +3,7 @@ import { getDatabase, runInTransaction } from "../db.js";
3
3
  import { HttpError } from "../errors.js";
4
4
  import { computeWorkTime } from "../services/work-time.js";
5
5
  import { recordActivityEvent } from "./activity-events.js";
6
+ import { bindTaskRunToTimebox, evaluateSchedulingForTask, finalizeTaskRunTimebox, heartbeatTaskRunTimebox } from "./calendar.js";
6
7
  import { createLinkedNotes } from "./notes.js";
7
8
  import { recordTaskRunCompletionReward, recordTaskRunProgressRewards, recordTaskRunStartReward } from "./rewards.js";
8
9
  import { getTaskById, updateTaskInTransaction } from "./tasks.js";
@@ -27,6 +28,7 @@ function selectClause() {
27
28
  task_runs.completed_at,
28
29
  task_runs.released_at,
29
30
  task_runs.timed_out_at,
31
+ task_runs.override_reason,
30
32
  task_runs.updated_at,
31
33
  tasks.title AS task_title
32
34
  FROM task_runs
@@ -74,6 +76,7 @@ function mapTaskRun(row, now = new Date(), cached = computeWorkTime(now)) {
74
76
  completedAt: row.completed_at,
75
77
  releasedAt: row.released_at,
76
78
  timedOutAt: row.timed_out_at,
79
+ overrideReason: row.override_reason ?? null,
77
80
  updatedAt: row.updated_at
78
81
  });
79
82
  }
@@ -289,6 +292,15 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
289
292
  const parsedInput = taskRunClaimSchema.parse(input);
290
293
  markExpiredRunsTimedOutInTransaction(now);
291
294
  requireKnownTask(taskId);
295
+ const task = getTaskById(taskId);
296
+ const scheduling = evaluateSchedulingForTask(task, now);
297
+ if (scheduling.blocked && (!parsedInput.overrideReason || parsedInput.overrideReason.trim().length === 0)) {
298
+ throw new HttpError(409, "task_run_calendar_blocked", `Calendar rules block starting ${task.title} right now. Add an override reason to proceed.`, {
299
+ taskId,
300
+ conflicts: scheduling.conflicts,
301
+ effectiveRules: scheduling.effectiveRules
302
+ });
303
+ }
292
304
  const existing = getActiveTaskRunRow(taskId, now);
293
305
  const nowIso = now.toISOString();
294
306
  if (existing) {
@@ -298,9 +310,9 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
298
310
  const nextExpiry = leaseExpiry(now, parsedInput.leaseTtlSeconds);
299
311
  getDatabase()
300
312
  .prepare(`UPDATE task_runs
301
- SET timer_mode = ?, planned_duration_seconds = ?, is_current = ?, heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, updated_at = ?
313
+ SET timer_mode = ?, planned_duration_seconds = ?, is_current = ?, heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, override_reason = ?, updated_at = ?
302
314
  WHERE id = ?`)
303
- .run(parsedInput.timerMode, parsedInput.plannedDurationSeconds, existing.is_current, nowIso, nextExpiry, parsedInput.leaseTtlSeconds, parsedInput.note, nowIso, existing.id);
315
+ .run(parsedInput.timerMode, parsedInput.plannedDurationSeconds, existing.is_current, nowIso, nextExpiry, parsedInput.leaseTtlSeconds, parsedInput.note, parsedInput.overrideReason ?? null, nowIso, existing.id);
304
316
  maybeSetCurrentRun(parsedInput.actor, existing.id, parsedInput.isCurrent, now);
305
317
  touchTaskInProgress(taskId, parsedInput.actor, activity.source);
306
318
  recordActivityEvent({
@@ -315,9 +327,15 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
315
327
  taskId,
316
328
  leaseTtlSeconds: parsedInput.leaseTtlSeconds,
317
329
  timerMode: parsedInput.timerMode,
318
- plannedDurationSeconds: parsedInput.plannedDurationSeconds
330
+ plannedDurationSeconds: parsedInput.plannedDurationSeconds,
331
+ overrideReason: parsedInput.overrideReason ?? null
319
332
  }
320
333
  });
334
+ heartbeatTaskRunTimebox(existing.id, {
335
+ title: task.title,
336
+ endsAt: nextExpiry,
337
+ overrideReason: parsedInput.overrideReason ?? null
338
+ });
321
339
  const cached = computeWorkTime(now);
322
340
  return {
323
341
  run: mapTaskRun(requireRun(existing.id), now, cached),
@@ -329,13 +347,22 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
329
347
  const expiry = leaseExpiry(now, parsedInput.leaseTtlSeconds);
330
348
  getDatabase()
331
349
  .prepare(`INSERT INTO task_runs (
332
- id, task_id, actor, status, timer_mode, planned_duration_seconds, is_current, note, lease_ttl_seconds, claimed_at, heartbeat_at, lease_expires_at, updated_at
350
+ id, task_id, actor, status, timer_mode, planned_duration_seconds, is_current, note, lease_ttl_seconds, claimed_at, heartbeat_at, lease_expires_at, override_reason, updated_at
333
351
  )
334
- VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
335
- .run(runId, taskId, parsedInput.actor, parsedInput.timerMode, parsedInput.plannedDurationSeconds, 0, parsedInput.note, parsedInput.leaseTtlSeconds, nowIso, nowIso, expiry, nowIso);
352
+ VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
353
+ .run(runId, taskId, parsedInput.actor, parsedInput.timerMode, parsedInput.plannedDurationSeconds, 0, parsedInput.note, parsedInput.leaseTtlSeconds, nowIso, nowIso, expiry, parsedInput.overrideReason ?? null, nowIso);
336
354
  maybeSetCurrentRun(parsedInput.actor, runId, parsedInput.isCurrent, now);
337
355
  touchTaskInProgress(taskId, parsedInput.actor, activity.source);
338
356
  const run = mapTaskRun(requireRun(runId), now);
357
+ bindTaskRunToTimebox({
358
+ taskId,
359
+ taskRunId: run.id,
360
+ startedAt: now,
361
+ title: task.title,
362
+ projectId: task.projectId,
363
+ plannedDurationSeconds: parsedInput.plannedDurationSeconds,
364
+ overrideReason: parsedInput.overrideReason ?? null
365
+ });
339
366
  recordActivityEvent({
340
367
  entityType: "task_run",
341
368
  entityId: run.id,
@@ -350,7 +377,8 @@ export function claimTaskRun(taskId, input, now = new Date(), activity = { sourc
350
377
  taskId: run.taskId,
351
378
  leaseTtlSeconds: run.leaseTtlSeconds,
352
379
  timerMode: run.timerMode,
353
- plannedDurationSeconds: run.plannedDurationSeconds
380
+ plannedDurationSeconds: run.plannedDurationSeconds,
381
+ overrideReason: parsedInput.overrideReason ?? null
354
382
  }
355
383
  });
356
384
  recordTaskRunStartReward(run.id, run.taskId, run.actor, activity.source);
@@ -370,15 +398,16 @@ export function heartbeatTaskRun(taskRunId, input, now = new Date(), activity =
370
398
  const note = input.note ?? current.note;
371
399
  getDatabase()
372
400
  .prepare(`UPDATE task_runs
373
- SET heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, updated_at = ?
401
+ SET heartbeat_at = ?, lease_expires_at = ?, lease_ttl_seconds = ?, note = ?, override_reason = ?, updated_at = ?
374
402
  WHERE id = ?`)
375
- .run(nowIso, nextExpiry, input.leaseTtlSeconds, note, nowIso, taskRunId);
403
+ .run(nowIso, nextExpiry, input.leaseTtlSeconds, note, input.overrideReason ?? current.override_reason, nowIso, taskRunId);
376
404
  const run = mapTaskRun({
377
405
  ...current,
378
406
  heartbeat_at: nowIso,
379
407
  lease_expires_at: nextExpiry,
380
408
  lease_ttl_seconds: input.leaseTtlSeconds,
381
409
  note,
410
+ override_reason: input.overrideReason ?? current.override_reason,
382
411
  updated_at: nowIso
383
412
  }, now);
384
413
  recordActivityEvent({
@@ -391,9 +420,15 @@ export function heartbeatTaskRun(taskRunId, input, now = new Date(), activity =
391
420
  source: activity.source,
392
421
  metadata: {
393
422
  taskId: run.taskId,
394
- leaseTtlSeconds: run.leaseTtlSeconds
423
+ leaseTtlSeconds: run.leaseTtlSeconds,
424
+ overrideReason: input.overrideReason ?? null
395
425
  }
396
426
  });
427
+ heartbeatTaskRunTimebox(taskRunId, {
428
+ title: run.taskTitle,
429
+ endsAt: nextExpiry,
430
+ overrideReason: input.overrideReason ?? null
431
+ });
397
432
  recordTaskRunProgressRewards(run.id, run.taskId, input.actor ?? run.actor, activity.source, run.creditedSeconds);
398
433
  return run;
399
434
  });
@@ -451,6 +486,7 @@ function finishTaskRun(taskRunId, nextStatus, timestampColumn, input, now, activ
451
486
  [timestampColumn]: nowIso,
452
487
  updated_at: nowIso
453
488
  }, now);
489
+ finalizeTaskRunTimebox(taskRunId, nextStatus === "completed" ? "completed" : "cancelled", nowIso);
454
490
  recordActivityEvent({
455
491
  entityType: "task_run",
456
492
  entityId: run.id,
@@ -11,7 +11,7 @@ import { pruneLinkedEntityReferences } from "./psyche.js";
11
11
  import { awardTaskCompletionReward, reverseLatestTaskCompletionReward } from "./rewards.js";
12
12
  import { assertTaskRelations } from "../services/relations.js";
13
13
  import { computeWorkTime, emptyTaskTimeSummary } from "../services/work-time.js";
14
- import { taskSchema } from "../types.js";
14
+ import { calendarSchedulingRulesSchema, taskSchema } from "../types.js";
15
15
  function readTaskTagIds(taskId) {
16
16
  const rows = getDatabase()
17
17
  .prepare(`SELECT tag_id FROM task_tags WHERE task_id = ? ORDER BY tag_id`)
@@ -32,6 +32,10 @@ function mapTask(row, time = emptyTaskTimeSummary()) {
32
32
  effort: row.effort,
33
33
  energy: row.energy,
34
34
  points: row.points,
35
+ plannedDurationSeconds: row.planned_duration_seconds,
36
+ schedulingRules: row.scheduling_rules_json === null
37
+ ? null
38
+ : calendarSchedulingRulesSchema.parse(JSON.parse(row.scheduling_rules_json)),
35
39
  sortOrder: row.sort_order,
36
40
  completedAt: row.completed_at,
37
41
  createdAt: row.created_at,
@@ -129,9 +133,17 @@ function updateTaskRecord(current, input, activity) {
129
133
  getDatabase()
130
134
  .prepare(`UPDATE tasks
131
135
  SET title = ?, description = ?, status = ?, priority = ?, owner = ?, goal_id = ?, due_date = ?, effort = ?,
132
- energy = ?, points = ?, sort_order = ?, completed_at = ?, updated_at = ?, project_id = ?
136
+ energy = ?, points = ?, planned_duration_seconds = ?, scheduling_rules_json = ?, sort_order = ?, completed_at = ?, updated_at = ?, project_id = ?
133
137
  WHERE id = ?`)
134
- .run(input.title ?? current.title, input.description ?? current.description, nextStatus, input.priority ?? current.priority, input.owner ?? current.owner, nextGoalId, input.dueDate === undefined ? current.dueDate : input.dueDate, input.effort ?? current.effort, input.energy ?? current.energy, input.points ?? current.points, nextSort, completedAt, updatedAt, nextProjectId, current.id);
138
+ .run(input.title ?? current.title, input.description ?? current.description, nextStatus, input.priority ?? current.priority, input.owner ?? current.owner, nextGoalId, input.dueDate === undefined ? current.dueDate : input.dueDate, input.effort ?? current.effort, input.energy ?? current.energy, input.points ?? current.points, input.plannedDurationSeconds === undefined
139
+ ? current.plannedDurationSeconds
140
+ : input.plannedDurationSeconds, input.schedulingRules === undefined
141
+ ? current.schedulingRules === null
142
+ ? null
143
+ : JSON.stringify(current.schedulingRules)
144
+ : input.schedulingRules === null
145
+ ? null
146
+ : JSON.stringify(input.schedulingRules), nextSort, completedAt, updatedAt, nextProjectId, current.id);
135
147
  replaceTaskTags(current.id, nextTagIds);
136
148
  const updated = getTaskById(current.id);
137
149
  if (updated && activity) {
@@ -210,6 +222,8 @@ function fingerprintTaskCreate(input) {
210
222
  effort: input.effort,
211
223
  energy: input.energy,
212
224
  points: input.points,
225
+ plannedDurationSeconds: input.plannedDurationSeconds,
226
+ schedulingRules: input.schedulingRules,
213
227
  sortOrder: input.sortOrder ?? null,
214
228
  tagIds: input.tagIds,
215
229
  notes: input.notes.map((note) => ({
@@ -232,11 +246,11 @@ function insertTaskRecord(input, activity) {
232
246
  const completedAt = normalizeCompletedAt(input.status, null);
233
247
  getDatabase()
234
248
  .prepare(`INSERT INTO tasks (
235
- id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points, sort_order,
236
- completed_at, created_at, updated_at
249
+ id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points,
250
+ planned_duration_seconds, scheduling_rules_json, sort_order, completed_at, created_at, updated_at
237
251
  )
238
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
239
- .run(id, input.title, input.description, input.status, input.priority, input.owner, relationState.goalId, relationState.projectId, input.dueDate, input.effort, input.energy, input.points, sortOrder, completedAt, now, now);
252
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
253
+ .run(id, input.title, input.description, input.status, input.priority, input.owner, relationState.goalId, relationState.projectId, input.dueDate, input.effort, input.energy, input.points, input.plannedDurationSeconds, input.schedulingRules === null ? null : JSON.stringify(input.schedulingRules), sortOrder, completedAt, now, now);
240
254
  replaceTaskTags(id, input.tagIds);
241
255
  const task = getTaskById(id);
242
256
  if (activity) {
@@ -306,7 +320,8 @@ export function listTasks(filters = {}) {
306
320
  params.push(filters.limit);
307
321
  }
308
322
  const rows = getDatabase()
309
- .prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points, sort_order,
323
+ .prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points,
324
+ planned_duration_seconds, scheduling_rules_json, sort_order,
310
325
  completed_at, created_at, updated_at
311
326
  FROM tasks
312
327
  ${whereSql}
@@ -330,7 +345,8 @@ export function getTaskById(taskId) {
330
345
  return undefined;
331
346
  }
332
347
  const row = getDatabase()
333
- .prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points, sort_order,
348
+ .prepare(`SELECT id, title, description, status, priority, owner, goal_id, project_id, due_date, effort, energy, points,
349
+ planned_duration_seconds, scheduling_rules_json, sort_order,
334
350
  completed_at, created_at, updated_at
335
351
  FROM tasks
336
352
  WHERE id = ?`)
@@ -0,0 +1,109 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDatabase, runInTransaction } from "../db.js";
3
+ import { recordActivityEvent } from "./activity-events.js";
4
+ import { getRewardLedgerEventById, recordWeeklyReviewCompletionReward } from "./rewards.js";
5
+ import { weeklyReviewClosureSchema } from "../types.js";
6
+ function mapWeeklyReviewClosure(row) {
7
+ return weeklyReviewClosureSchema.parse({
8
+ id: row.id,
9
+ weekKey: row.week_key,
10
+ weekStartDate: row.week_start_date,
11
+ weekEndDate: row.week_end_date,
12
+ windowLabel: row.window_label,
13
+ actor: row.actor,
14
+ source: row.source,
15
+ rewardId: row.reward_id,
16
+ activityEventId: row.activity_event_id,
17
+ createdAt: row.created_at
18
+ });
19
+ }
20
+ export function getWeeklyReviewClosure(weekKey) {
21
+ const row = getDatabase()
22
+ .prepare(`SELECT
23
+ id,
24
+ week_key,
25
+ week_start_date,
26
+ week_end_date,
27
+ window_label,
28
+ actor,
29
+ source,
30
+ reward_id,
31
+ activity_event_id,
32
+ created_at
33
+ FROM weekly_review_closures
34
+ WHERE week_key = ?`)
35
+ .get(weekKey);
36
+ return row ? mapWeeklyReviewClosure(row) : null;
37
+ }
38
+ export function finalizeWeeklyReviewClosure(input) {
39
+ return runInTransaction(() => {
40
+ const existing = getWeeklyReviewClosure(input.weekKey);
41
+ if (existing) {
42
+ const existingReward = getRewardLedgerEventById(existing.rewardId);
43
+ if (!existingReward) {
44
+ throw new Error(`Weekly review closure ${existing.id} is missing reward ${existing.rewardId}.`);
45
+ }
46
+ return {
47
+ closure: existing,
48
+ reward: existingReward,
49
+ created: false
50
+ };
51
+ }
52
+ const reward = recordWeeklyReviewCompletionReward({
53
+ weekKey: input.weekKey,
54
+ windowLabel: input.windowLabel,
55
+ rewardXp: input.rewardXp
56
+ }, {
57
+ actor: input.actor ?? null,
58
+ source: input.source
59
+ });
60
+ const activity = recordActivityEvent({
61
+ entityType: "system",
62
+ entityId: input.weekKey,
63
+ eventType: "weekly_review_finalized",
64
+ title: `Weekly review finalized: ${input.windowLabel}`,
65
+ description: `Review completion locked this cycle and awarded ${reward.deltaXp} XP.`,
66
+ actor: input.actor ?? null,
67
+ source: input.source,
68
+ metadata: {
69
+ weekKey: input.weekKey,
70
+ weekStartDate: input.weekStartDate,
71
+ weekEndDate: input.weekEndDate,
72
+ rewardId: reward.id,
73
+ rewardXp: reward.deltaXp
74
+ }
75
+ });
76
+ const createdAt = new Date().toISOString();
77
+ const closure = weeklyReviewClosureSchema.parse({
78
+ id: `wrc_${randomUUID().replaceAll("-", "").slice(0, 10)}`,
79
+ weekKey: input.weekKey,
80
+ weekStartDate: input.weekStartDate,
81
+ weekEndDate: input.weekEndDate,
82
+ windowLabel: input.windowLabel,
83
+ actor: input.actor ?? null,
84
+ source: input.source,
85
+ rewardId: reward.id,
86
+ activityEventId: activity.id,
87
+ createdAt
88
+ });
89
+ getDatabase()
90
+ .prepare(`INSERT INTO weekly_review_closures (
91
+ id,
92
+ week_key,
93
+ week_start_date,
94
+ week_end_date,
95
+ window_label,
96
+ actor,
97
+ source,
98
+ reward_id,
99
+ activity_event_id,
100
+ created_at
101
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
102
+ .run(closure.id, closure.weekKey, closure.weekStartDate, closure.weekEndDate, closure.windowLabel, closure.actor, closure.source, closure.rewardId, closure.activityEventId, closure.createdAt);
103
+ return {
104
+ closure,
105
+ reward,
106
+ created: true
107
+ };
108
+ });
109
+ }
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDatabase } from "../db.js";
3
+ import { createWorkAdjustmentSchema, workAdjustmentSchema } from "../types.js";
4
+ function mapWorkAdjustment(row) {
5
+ return workAdjustmentSchema.parse({
6
+ id: row.id,
7
+ entityType: row.entity_type,
8
+ entityId: row.entity_id,
9
+ requestedDeltaMinutes: row.requested_delta_minutes,
10
+ appliedDeltaMinutes: row.applied_delta_minutes,
11
+ note: row.note,
12
+ actor: row.actor,
13
+ source: row.source,
14
+ createdAt: row.created_at
15
+ });
16
+ }
17
+ export function createWorkAdjustment(input, activity, now = new Date()) {
18
+ const parsed = createWorkAdjustmentSchema.parse(input);
19
+ const adjustment = workAdjustmentSchema.parse({
20
+ id: `wadj_${randomUUID().replaceAll("-", "").slice(0, 10)}`,
21
+ entityType: parsed.entityType,
22
+ entityId: parsed.entityId,
23
+ requestedDeltaMinutes: parsed.deltaMinutes,
24
+ appliedDeltaMinutes: input.appliedDeltaMinutes,
25
+ note: parsed.note,
26
+ actor: activity.actor ?? null,
27
+ source: activity.source,
28
+ createdAt: now.toISOString()
29
+ });
30
+ getDatabase()
31
+ .prepare(`INSERT INTO work_adjustments (
32
+ id,
33
+ entity_type,
34
+ entity_id,
35
+ requested_delta_minutes,
36
+ applied_delta_minutes,
37
+ note,
38
+ actor,
39
+ source,
40
+ created_at
41
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
42
+ .run(adjustment.id, adjustment.entityType, adjustment.entityId, adjustment.requestedDeltaMinutes, adjustment.appliedDeltaMinutes, adjustment.note, adjustment.actor, adjustment.source, adjustment.createdAt);
43
+ return adjustment;
44
+ }
45
+ export function listWorkAdjustmentsForEntity(entityType, entityId, limit = 50) {
46
+ const rows = getDatabase()
47
+ .prepare(`SELECT
48
+ id,
49
+ entity_type,
50
+ entity_id,
51
+ requested_delta_minutes,
52
+ applied_delta_minutes,
53
+ note,
54
+ actor,
55
+ source,
56
+ created_at
57
+ FROM work_adjustments
58
+ WHERE entity_type = ? AND entity_id = ?
59
+ ORDER BY created_at DESC
60
+ LIMIT ?`)
61
+ .all(entityType, entityId, limit);
62
+ return rows.map(mapWorkAdjustment);
63
+ }
64
+ export function getWorkAdjustmentSecondsByEntity(entityType, entityId) {
65
+ const row = getDatabase()
66
+ .prepare(`SELECT COALESCE(SUM(applied_delta_minutes), 0) AS total_minutes
67
+ FROM work_adjustments
68
+ WHERE entity_type = ? AND entity_id = ?`)
69
+ .get(entityType, entityId);
70
+ return Math.trunc((row?.total_minutes ?? 0) * 60);
71
+ }
72
+ function getWorkAdjustmentSecondsMap(entityType, entityIds) {
73
+ if (entityIds.length === 0) {
74
+ return new Map();
75
+ }
76
+ const placeholders = entityIds.map(() => "?").join(", ");
77
+ const rows = getDatabase()
78
+ .prepare(`SELECT entity_id, COALESCE(SUM(applied_delta_minutes), 0) AS total_minutes
79
+ FROM work_adjustments
80
+ WHERE entity_type = ? AND entity_id IN (${placeholders})
81
+ GROUP BY entity_id`)
82
+ .all(entityType, ...entityIds);
83
+ return new Map(rows.map((row) => [row.entity_id, Math.trunc(row.total_minutes * 60)]));
84
+ }
85
+ function listWorkAdjustmentSecondsMap(entityType) {
86
+ const rows = getDatabase()
87
+ .prepare(`SELECT entity_id, COALESCE(SUM(applied_delta_minutes), 0) AS total_minutes
88
+ FROM work_adjustments
89
+ WHERE entity_type = ?
90
+ GROUP BY entity_id`)
91
+ .all(entityType);
92
+ return new Map(rows.map((row) => [row.entity_id, Math.trunc(row.total_minutes * 60)]));
93
+ }
94
+ export function getTaskWorkAdjustmentSecondsMap(taskIds) {
95
+ return getWorkAdjustmentSecondsMap("task", taskIds);
96
+ }
97
+ export function getProjectWorkAdjustmentSecondsMap(projectIds) {
98
+ return getWorkAdjustmentSecondsMap("project", projectIds);
99
+ }
100
+ export function listTaskWorkAdjustmentSecondsMap() {
101
+ return listWorkAdjustmentSecondsMap("task");
102
+ }
103
+ export function listProjectWorkAdjustmentSecondsMap() {
104
+ return listWorkAdjustmentSecondsMap("project");
105
+ }