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
@@ -114,6 +114,8 @@ export function cascadeSoftDeleteAnchoredCollaboration(parentEntityType, parentE
114
114
  notes.content_plain AS content_plain,
115
115
  notes.author AS author,
116
116
  notes.source AS source,
117
+ notes.tags_json AS tags_json,
118
+ notes.destroy_at AS destroy_at,
117
119
  notes.created_at AS created_at,
118
120
  notes.updated_at AS updated_at
119
121
  FROM notes
@@ -145,18 +147,24 @@ export function cascadeSoftDeleteAnchoredCollaboration(parentEntityType, parentE
145
147
  linksByNoteId.set(link.note_id, current);
146
148
  }
147
149
  for (const row of noteRows) {
148
- const compact = (row.content_plain || row.content_markdown).replace(/\s+/g, " ").trim();
150
+ const compact = (row.content_plain || row.content_markdown)
151
+ .replace(/\s+/g, " ")
152
+ .trim();
149
153
  upsertDeletedEntityRecord({
150
154
  entityType: "note",
151
155
  entityId: row.id,
152
156
  title: compact.slice(0, 72) || "Note",
153
- subtitle: compact.length > 72 ? compact.slice(72, 168).trim() : `Linked to ${parentEntityType.replaceAll("_", " ")}`,
157
+ subtitle: compact.length > 72
158
+ ? compact.slice(72, 168).trim()
159
+ : `Linked to ${parentEntityType.replaceAll("_", " ")}`,
154
160
  snapshot: {
155
161
  id: row.id,
156
162
  contentMarkdown: row.content_markdown,
157
163
  contentPlain: row.content_plain,
158
164
  author: row.author,
159
165
  source: row.source,
166
+ tags: JSON.parse(row.tags_json),
167
+ destroyAt: row.destroy_at,
160
168
  createdAt: row.created_at,
161
169
  updatedAt: row.updated_at,
162
170
  links: linksByNoteId.get(row.id) ?? []
@@ -18,6 +18,53 @@ function normalizeLinks(links) {
18
18
  return true;
19
19
  });
20
20
  }
21
+ function normalizeTags(tags) {
22
+ if (!tags) {
23
+ return [];
24
+ }
25
+ const seen = new Set();
26
+ return tags
27
+ .map((tag) => tag.trim())
28
+ .filter(Boolean)
29
+ .filter((tag) => {
30
+ const normalized = tag.toLowerCase();
31
+ if (seen.has(normalized)) {
32
+ return false;
33
+ }
34
+ seen.add(normalized);
35
+ return true;
36
+ });
37
+ }
38
+ function parseTagsJson(raw) {
39
+ try {
40
+ const parsed = JSON.parse(raw);
41
+ return Array.isArray(parsed)
42
+ ? normalizeTags(parsed.filter((value) => typeof value === "string"))
43
+ : [];
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ }
49
+ function noteMatchesTextTerm(note, term) {
50
+ const normalized = term.trim().toLowerCase();
51
+ if (!normalized) {
52
+ return false;
53
+ }
54
+ return note.tags.some((tag) => tag.toLowerCase().includes(normalized));
55
+ }
56
+ function cleanupExpiredNotes() {
57
+ const expiredRows = getDatabase()
58
+ .prepare(`SELECT id
59
+ FROM notes
60
+ WHERE destroy_at IS NOT NULL
61
+ AND destroy_at != ''
62
+ AND destroy_at <= ?`)
63
+ .all(new Date().toISOString());
64
+ for (const row of expiredRows) {
65
+ deleteNoteInternal(row.id, { source: "system", actor: null }, "Ephemeral note expired");
66
+ }
67
+ }
21
68
  function stripMarkdown(markdown) {
22
69
  return markdown
23
70
  .replace(/```[\s\S]*?```/g, (block) => block.replace(/```/g, "").trim())
@@ -55,7 +102,7 @@ function buildFtsQuery(query) {
55
102
  }
56
103
  function getNoteRow(noteId) {
57
104
  return getDatabase()
58
- .prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
105
+ .prepare(`SELECT id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at
59
106
  FROM notes
60
107
  WHERE id = ?`)
61
108
  .get(noteId);
@@ -86,6 +133,8 @@ function mapNote(row, linkRows) {
86
133
  contentPlain: row.content_plain,
87
134
  author: row.author,
88
135
  source: row.source,
136
+ tags: parseTagsJson(row.tags_json),
137
+ destroyAt: row.destroy_at,
89
138
  createdAt: row.created_at,
90
139
  updatedAt: row.updated_at,
91
140
  links: mapLinks(linkRows)
@@ -102,7 +151,7 @@ function deleteSearchRow(noteId) {
102
151
  }
103
152
  function listAllNoteRows() {
104
153
  return getDatabase()
105
- .prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
154
+ .prepare(`SELECT id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at
106
155
  FROM notes
107
156
  ORDER BY created_at DESC`)
108
157
  .all();
@@ -117,6 +166,19 @@ function findMatchingNoteIds(query) {
117
166
  .all(ftsQuery);
118
167
  return new Set(rows.map((row) => row.note_id));
119
168
  }
169
+ function findMatchingNoteIdsForTextTerms(terms) {
170
+ const normalizedTerms = terms.map((term) => term.trim()).filter(Boolean);
171
+ if (normalizedTerms.length === 0) {
172
+ return null;
173
+ }
174
+ const matches = new Set();
175
+ for (const term of normalizedTerms) {
176
+ for (const noteId of findMatchingNoteIds(term)) {
177
+ matches.add(noteId);
178
+ }
179
+ }
180
+ return matches;
181
+ }
120
182
  function insertLinks(noteId, links, createdAt) {
121
183
  const statement = getDatabase().prepare(`INSERT OR IGNORE INTO note_links (note_id, entity_type, entity_id, anchor_key, created_at)
122
184
  VALUES (?, ?, ?, ?, ?)`);
@@ -164,7 +226,10 @@ function recordNoteActivity(note, eventType, title, context) {
164
226
  });
165
227
  }
166
228
  }
167
- export function getNoteById(noteId) {
229
+ export function getNoteById(noteId, options = {}) {
230
+ if (!options.skipCleanup) {
231
+ cleanupExpiredNotes();
232
+ }
168
233
  if (isEntityDeleted("note", noteId)) {
169
234
  return undefined;
170
235
  }
@@ -174,7 +239,10 @@ export function getNoteById(noteId) {
174
239
  }
175
240
  return mapNote(row, listNoteLinks(noteId));
176
241
  }
177
- export function getNoteByIdIncludingDeleted(noteId) {
242
+ export function getNoteByIdIncludingDeleted(noteId, options = {}) {
243
+ if (!options.skipCleanup) {
244
+ cleanupExpiredNotes();
245
+ }
178
246
  const row = getNoteRow(noteId);
179
247
  if (!row) {
180
248
  const deleted = getDeletedEntityRecord("note", noteId);
@@ -183,13 +251,26 @@ export function getNoteByIdIncludingDeleted(noteId) {
183
251
  return mapNote(row, listNoteLinks(noteId));
184
252
  }
185
253
  export function listNotes(query = {}) {
254
+ cleanupExpiredNotes();
186
255
  const parsed = notesListQuerySchema.parse(query);
187
- if (parsed.linkedEntityType &&
188
- parsed.linkedEntityId &&
189
- isEntityDeleted(parsed.linkedEntityType, parsed.linkedEntityId)) {
256
+ const linkedFilters = [
257
+ ...(parsed.linkedEntityType && parsed.linkedEntityId
258
+ ? [
259
+ {
260
+ entityType: parsed.linkedEntityType,
261
+ entityId: parsed.linkedEntityId
262
+ }
263
+ ]
264
+ : []),
265
+ ...parsed.linkedTo
266
+ ];
267
+ if (linkedFilters.some((filter) => isEntityDeleted(filter.entityType, filter.entityId))) {
190
268
  return [];
191
269
  }
192
- const matchingIds = parsed.query ? findMatchingNoteIds(parsed.query) : null;
270
+ const matchingIds = findMatchingNoteIdsForTextTerms([
271
+ ...(parsed.query ? [parsed.query] : []),
272
+ ...parsed.textTerms
273
+ ]);
193
274
  const rows = listAllNoteRows();
194
275
  const linksByNoteId = new Map();
195
276
  for (const link of listLinkRowsForNotes(rows.map((row) => row.id))) {
@@ -198,32 +279,63 @@ export function listNotes(query = {}) {
198
279
  linksByNoteId.set(link.note_id, current);
199
280
  }
200
281
  return filterDeletedEntities("note", rows
201
- .filter((row) => (matchingIds ? matchingIds.has(row.id) : true))
202
- .filter((row) => (parsed.author ? (row.author ?? "").toLowerCase().includes(parsed.author.toLowerCase()) : true))
282
+ .filter((row) => parsed.author
283
+ ? (row.author ?? "")
284
+ .toLowerCase()
285
+ .includes(parsed.author.toLowerCase())
286
+ : true)
203
287
  .map((row) => mapNote(row, linksByNoteId.get(row.id) ?? []))
204
- .filter((note) => parsed.linkedEntityType && parsed.linkedEntityId
205
- ? note.links.some((link) => link.entityType === parsed.linkedEntityType &&
206
- link.entityId === parsed.linkedEntityId &&
207
- (parsed.anchorKey === undefined ? true : (link.anchorKey ?? null) === parsed.anchorKey))
288
+ .filter((note) => {
289
+ if (!matchingIds) {
290
+ return true;
291
+ }
292
+ return (matchingIds.has(note.id) ||
293
+ parsed.textTerms.some((term) => noteMatchesTextTerm(note, term)) ||
294
+ (parsed.query ? noteMatchesTextTerm(note, parsed.query) : false));
295
+ })
296
+ .filter((note) => linkedFilters.length > 0
297
+ ? note.links.some((link) => linkedFilters.some((filter) => link.entityType === filter.entityType &&
298
+ link.entityId === filter.entityId &&
299
+ (parsed.anchorKey === undefined
300
+ ? true
301
+ : (link.anchorKey ?? null) === parsed.anchorKey)))
302
+ : true)
303
+ .filter((note) => parsed.tags.length > 0
304
+ ? parsed.tags.every((filterTag) => note.tags.some((noteTag) => noteTag.toLowerCase() === filterTag.toLowerCase()))
208
305
  : true)
306
+ .filter((note) => {
307
+ if (!parsed.updatedFrom && !parsed.updatedTo) {
308
+ return true;
309
+ }
310
+ const updatedDate = note.updatedAt.slice(0, 10);
311
+ if (parsed.updatedFrom && updatedDate < parsed.updatedFrom) {
312
+ return false;
313
+ }
314
+ if (parsed.updatedTo && updatedDate > parsed.updatedTo) {
315
+ return false;
316
+ }
317
+ return true;
318
+ })
209
319
  .slice(0, parsed.limit ?? 100));
210
320
  }
211
321
  export function createNote(input, context) {
322
+ cleanupExpiredNotes();
212
323
  const parsed = createNoteSchema.parse({
213
324
  ...input,
214
- links: normalizeLinks(input.links)
325
+ links: normalizeLinks(input.links),
326
+ tags: normalizeTags(input.tags)
215
327
  });
216
328
  const now = new Date().toISOString();
217
329
  const id = `note_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
218
330
  const contentPlain = stripMarkdown(parsed.contentMarkdown);
219
331
  getDatabase()
220
- .prepare(`INSERT INTO notes (id, content_markdown, content_plain, author, source, created_at, updated_at)
221
- VALUES (?, ?, ?, ?, ?, ?, ?)`)
222
- .run(id, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, now, now);
332
+ .prepare(`INSERT INTO notes (id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at)
333
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
334
+ .run(id, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, JSON.stringify(parsed.tags), parsed.destroyAt, now, now);
223
335
  insertLinks(id, parsed.links, now);
224
336
  clearDeletedEntityRecord("note", id);
225
337
  upsertSearchRow(id, contentPlain, parsed.author ?? context.actor ?? null);
226
- const note = getNoteById(id);
338
+ const note = getNoteById(id, { skipCleanup: true });
227
339
  recordNoteActivity(note, "note.created", "Note added", context);
228
340
  return note;
229
341
  }
@@ -234,31 +346,37 @@ export function createLinkedNotes(notes, entityLink, context) {
234
346
  return notes.map((note) => createNote({
235
347
  contentMarkdown: note.contentMarkdown,
236
348
  author: note.author,
349
+ tags: note.tags,
350
+ destroyAt: note.destroyAt,
237
351
  links: [entityLink, ...note.links]
238
352
  }, context));
239
353
  }
240
354
  export function updateNote(noteId, input, context) {
241
- const existing = getNoteByIdIncludingDeleted(noteId);
355
+ cleanupExpiredNotes();
356
+ const existing = getNoteByIdIncludingDeleted(noteId, { skipCleanup: true });
242
357
  if (!existing) {
243
358
  return undefined;
244
359
  }
245
360
  const patch = updateNoteSchema.parse({
246
361
  ...input,
247
- links: input.links ? normalizeLinks(input.links) : undefined
362
+ links: input.links ? normalizeLinks(input.links) : undefined,
363
+ tags: input.tags ? normalizeTags(input.tags) : undefined
248
364
  });
249
365
  const nextMarkdown = patch.contentMarkdown ?? existing.contentMarkdown;
250
366
  const nextPlain = stripMarkdown(nextMarkdown);
251
367
  const nextAuthor = patch.author === undefined ? existing.author : patch.author;
368
+ const nextTags = patch.tags ?? existing.tags;
369
+ const nextDestroyAt = patch.destroyAt === undefined ? existing.destroyAt : patch.destroyAt;
252
370
  const updatedAt = new Date().toISOString();
253
371
  getDatabase()
254
372
  .prepare(`UPDATE notes
255
- SET content_markdown = ?, content_plain = ?, author = ?, updated_at = ?
373
+ SET content_markdown = ?, content_plain = ?, author = ?, tags_json = ?, destroy_at = ?, updated_at = ?
256
374
  WHERE id = ?`)
257
- .run(nextMarkdown, nextPlain, nextAuthor, updatedAt, noteId);
375
+ .run(nextMarkdown, nextPlain, nextAuthor, JSON.stringify(nextTags), nextDestroyAt, updatedAt, noteId);
258
376
  if (patch.links) {
259
377
  replaceLinks(noteId, patch.links, updatedAt);
260
378
  }
261
- const note = getNoteByIdIncludingDeleted(noteId);
379
+ const note = getNoteByIdIncludingDeleted(noteId, { skipCleanup: true });
262
380
  if (note.links.length > 0) {
263
381
  clearDeletedEntityRecord("note", noteId);
264
382
  }
@@ -275,21 +393,34 @@ export function updateNote(noteId, input, context) {
275
393
  });
276
394
  }
277
395
  upsertSearchRow(noteId, nextPlain, nextAuthor);
396
+ if (nextDestroyAt && Date.parse(nextDestroyAt) <= Date.now()) {
397
+ deleteNoteInternal(noteId, { source: "system", actor: null }, "Ephemeral note expired");
398
+ return undefined;
399
+ }
278
400
  recordNoteActivity(note, "note.updated", "Note updated", context);
279
401
  return getNoteById(noteId);
280
402
  }
281
- export function deleteNote(noteId, context) {
282
- const existing = getNoteByIdIncludingDeleted(noteId);
403
+ function deleteNoteInternal(noteId, context, title) {
404
+ const existing = getNoteRow(noteId)
405
+ ? mapNote(getNoteRow(noteId), listNoteLinks(noteId))
406
+ : getDeletedEntityRecord("note", noteId)?.snapshot;
283
407
  if (!existing) {
284
408
  return undefined;
285
409
  }
410
+ clearDeletedEntityRecord("note", noteId);
286
411
  getDatabase().prepare(`DELETE FROM note_links WHERE note_id = ?`).run(noteId);
287
412
  getDatabase().prepare(`DELETE FROM notes WHERE id = ?`).run(noteId);
288
413
  deleteSearchRow(noteId);
289
- recordNoteActivity(existing, "note.deleted", "Note deleted", context);
414
+ clearDeletedEntityRecord("note", noteId);
415
+ recordNoteActivity(existing, "note.deleted", title, context);
290
416
  return existing;
291
417
  }
418
+ export function deleteNote(noteId, context) {
419
+ cleanupExpiredNotes();
420
+ return deleteNoteInternal(noteId, context, "Note deleted");
421
+ }
292
422
  export function buildNotesSummaryByEntity() {
423
+ cleanupExpiredNotes();
293
424
  const rows = getDatabase()
294
425
  .prepare(`SELECT
295
426
  note_links.entity_type AS entity_type,
@@ -302,8 +433,9 @@ export function buildNotesSummaryByEntity() {
302
433
  ON deleted_entities.entity_type = 'note'
303
434
  AND deleted_entities.entity_id = notes.id
304
435
  WHERE deleted_entities.entity_id IS NULL
436
+ AND (notes.destroy_at IS NULL OR notes.destroy_at = '' OR notes.destroy_at > ?)
305
437
  ORDER BY notes.created_at DESC`)
306
- .all();
438
+ .all(new Date().toISOString());
307
439
  return rows.reduce((acc, row) => {
308
440
  const key = `${row.entity_type}:${row.entity_id}`;
309
441
  const current = acc[key];
@@ -324,6 +456,7 @@ export function buildNotesSummaryByEntity() {
324
456
  }, {});
325
457
  }
326
458
  export function unlinkNotesForEntity(entityType, entityId, context) {
459
+ cleanupExpiredNotes();
327
460
  const noteIds = getDatabase()
328
461
  .prepare(`SELECT DISTINCT note_id FROM note_links WHERE entity_type = ? AND entity_id = ?`)
329
462
  .all(entityType, entityId);
@@ -6,7 +6,8 @@ import { createLinkedNotes } from "./notes.js";
6
6
  import { assertGoalExists } from "../services/relations.js";
7
7
  import { getGoalById } from "./goals.js";
8
8
  import { pruneLinkedEntityReferences } from "./psyche.js";
9
- import { createProjectSchema, projectSchema, updateProjectSchema } from "../types.js";
9
+ import { listTasks, updateTaskInTransaction } from "./tasks.js";
10
+ import { calendarSchedulingRulesSchema, createProjectSchema, projectSchema, updateProjectSchema } from "../types.js";
10
11
  function getDefaultProjectTemplate(goal) {
11
12
  switch (goal.title) {
12
13
  case "Build a durable body and calm energy":
@@ -40,10 +41,18 @@ function mapProject(row) {
40
41
  status: row.status,
41
42
  themeColor: row.theme_color,
42
43
  targetPoints: row.target_points,
44
+ schedulingRules: calendarSchedulingRulesSchema.parse(JSON.parse(row.scheduling_rules_json || "{}")),
43
45
  createdAt: row.created_at,
44
46
  updatedAt: row.updated_at
45
47
  });
46
48
  }
49
+ function completeLinkedProjectTasks(projectId, activity) {
50
+ const openTasks = listTasks({ projectId }).filter((task) => task.status !== "done");
51
+ for (const task of openTasks) {
52
+ updateTaskInTransaction(task.id, { status: "done" }, activity);
53
+ }
54
+ return openTasks.length;
55
+ }
47
56
  export function listProjects(filters = {}) {
48
57
  const whereClauses = [];
49
58
  const params = [];
@@ -62,6 +71,7 @@ export function listProjects(filters = {}) {
62
71
  }
63
72
  const rows = getDatabase()
64
73
  .prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
74
+ , scheduling_rules_json
65
75
  FROM projects
66
76
  ${whereSql}
67
77
  ORDER BY created_at ASC
@@ -75,6 +85,7 @@ export function getProjectById(projectId) {
75
85
  }
76
86
  const row = getDatabase()
77
87
  .prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
88
+ , scheduling_rules_json
78
89
  FROM projects
79
90
  WHERE id = ?`)
80
91
  .get(projectId);
@@ -87,9 +98,9 @@ export function createProject(input, activity) {
87
98
  const now = new Date().toISOString();
88
99
  const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
89
100
  getDatabase()
90
- .prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at)
91
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
92
- .run(id, parsed.goalId, parsed.title, parsed.description, parsed.status, parsed.themeColor, parsed.targetPoints, now, now);
101
+ .prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, scheduling_rules_json, created_at, updated_at)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
103
+ .run(id, parsed.goalId, parsed.title, parsed.description, parsed.status, parsed.themeColor, parsed.targetPoints, JSON.stringify(parsed.schedulingRules), now, now);
93
104
  const project = getProjectById(id);
94
105
  createLinkedNotes(parsed.notes, { entityType: "project", entityId: project.id, anchorKey: null }, activity ?? { source: "ui", actor: null });
95
106
  if (activity) {
@@ -127,32 +138,42 @@ export function updateProject(projectId, input, activity) {
127
138
  status: parsed.status ?? current.status,
128
139
  themeColor: parsed.themeColor ?? current.themeColor,
129
140
  targetPoints: parsed.targetPoints ?? current.targetPoints,
141
+ schedulingRules: parsed.schedulingRules ?? current.schedulingRules,
130
142
  updatedAt: new Date().toISOString()
131
143
  };
132
144
  getDatabase()
133
145
  .prepare(`UPDATE projects
134
- SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, updated_at = ?
146
+ SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, scheduling_rules_json = ?, updated_at = ?
135
147
  WHERE id = ?`)
136
- .run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, next.updatedAt, projectId);
148
+ .run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, JSON.stringify(next.schedulingRules), next.updatedAt, projectId);
137
149
  // Keep legacy task.goal_id aligned with the project's parent goal.
138
150
  getDatabase()
139
151
  .prepare(`UPDATE tasks SET goal_id = ?, updated_at = ? WHERE project_id = ?`)
140
152
  .run(next.goalId, next.updatedAt, projectId);
153
+ const completedLinkedTaskCount = current.status !== "completed" && next.status === "completed"
154
+ ? completeLinkedProjectTasks(projectId, activity)
155
+ : 0;
141
156
  const project = getProjectById(projectId);
142
157
  if (project && activity) {
158
+ const statusChanged = current.status !== project.status;
143
159
  recordActivityEvent({
144
160
  entityType: "project",
145
161
  entityId: project.id,
146
- eventType: current.status !== project.status ? "project_status_changed" : "project_updated",
147
- title: current.status !== project.status ? `Project ${project.status}: ${project.title}` : `Project updated: ${project.title}`,
148
- description: "Project details were updated.",
162
+ eventType: statusChanged ? "project_status_changed" : "project_updated",
163
+ title: statusChanged ? `Project ${project.status}: ${project.title}` : `Project updated: ${project.title}`,
164
+ description: statusChanged && project.status === "completed"
165
+ ? `Project finished and auto-completed ${completedLinkedTaskCount} linked unfinished task${completedLinkedTaskCount === 1 ? "" : "s"}.`
166
+ : statusChanged
167
+ ? `Project status changed from ${current.status} to ${project.status}.`
168
+ : "Project details were updated.",
149
169
  actor: activity.actor ?? null,
150
170
  source: activity.source,
151
171
  metadata: {
152
172
  goalId: project.goalId,
153
173
  previousGoalId: current.goalId,
154
174
  status: project.status,
155
- previousStatus: current.status
175
+ previousStatus: current.status,
176
+ completedLinkedTaskCount
156
177
  }
157
178
  });
158
179
  }
@@ -174,9 +195,20 @@ export function ensureDefaultProjectForGoal(goalId) {
174
195
  const now = new Date().toISOString();
175
196
  const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
176
197
  getDatabase()
177
- .prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at)
178
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
179
- .run(id, goalId, template.title, template.description, goal.status === "completed" ? "completed" : goal.status === "paused" ? "paused" : "active", goal.themeColor, Math.max(100, Math.round(goal.targetPoints / 2)), now, now);
198
+ .prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, scheduling_rules_json, created_at, updated_at)
199
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
200
+ .run(id, goalId, template.title, template.description, goal.status === "completed" ? "completed" : goal.status === "paused" ? "paused" : "active", goal.themeColor, Math.max(100, Math.round(goal.targetPoints / 2)), JSON.stringify({
201
+ allowWorkBlockKinds: [],
202
+ blockWorkBlockKinds: [],
203
+ allowCalendarIds: [],
204
+ blockCalendarIds: [],
205
+ allowEventTypes: [],
206
+ blockEventTypes: [],
207
+ allowEventKeywords: [],
208
+ blockEventKeywords: [],
209
+ allowAvailability: [],
210
+ blockAvailability: []
211
+ }), now, now);
180
212
  return getProjectById(id);
181
213
  });
182
214
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getDatabase } from "../db.js";
3
3
  import { recordEventLog } from "./event-log.js";
4
- import { createManualRewardGrantSchema, rewardLedgerEventSchema, rewardRuleSchema, sessionEventSchema, updateRewardRuleSchema } from "../types.js";
4
+ import { createManualRewardGrantSchema, workAdjustmentEntityTypeSchema, rewardLedgerEventSchema, rewardRuleSchema, sessionEventSchema, updateRewardRuleSchema } from "../types.js";
5
5
  const DEFAULT_RULES = [
6
6
  {
7
7
  id: "reward_rule_task_completion",
@@ -107,6 +107,14 @@ const DEFAULT_RULES = [
107
107
  description: "Reward giving a recurring mode enough shape to recognize it later.",
108
108
  config: { fixedXp: 4 }
109
109
  },
110
+ {
111
+ id: "reward_rule_weekly_review_completed",
112
+ family: "alignment",
113
+ code: "weekly_review_completed",
114
+ title: "Weekly review completed",
115
+ description: "Reward closing the current weekly review cycle and turning it into explicit evidence.",
116
+ config: { fixedXp: 250 }
117
+ },
110
118
  {
111
119
  id: "reward_rule_session_dwell",
112
120
  family: "ambient",
@@ -194,6 +202,17 @@ export function getRewardRuleById(ruleId) {
194
202
  function getRuleByCode(code) {
195
203
  return listRewardRules().find((rule) => rule.code === code);
196
204
  }
205
+ export function getTaskRunProgressRewardCadence() {
206
+ ensureDefaultRewardRules();
207
+ const rule = getRuleByCode("task_run_progress");
208
+ const intervalMinutes = Math.max(1, Number(rule?.config.intervalMinutes ?? 10));
209
+ return {
210
+ rule,
211
+ intervalMinutes,
212
+ intervalSeconds: intervalMinutes * 60,
213
+ fixedXp: Number(rule?.config.fixedXp ?? 4)
214
+ };
215
+ }
197
216
  export function updateRewardRule(ruleId, input, activity) {
198
217
  ensureDefaultRewardRules();
199
218
  const current = getRewardRuleById(ruleId);
@@ -279,6 +298,17 @@ export function listRewardLedger(filters = {}) {
279
298
  .all(...params);
280
299
  return rows.map(mapLedger);
281
300
  }
301
+ export function getRewardLedgerEventById(rewardId) {
302
+ ensureDefaultRewardRules();
303
+ const row = getDatabase()
304
+ .prepare(`SELECT
305
+ id, rule_id, event_log_id, entity_type, entity_id, actor, source, delta_xp, reason_title, reason_summary,
306
+ reversible_group, reversed_by_reward_id, metadata_json, created_at
307
+ FROM reward_ledger
308
+ WHERE id = ?`)
309
+ .get(rewardId);
310
+ return row ? mapLedger(row) : null;
311
+ }
282
312
  export function getTotalXp() {
283
313
  ensureDefaultRewardRules();
284
314
  const row = getDatabase().prepare(`SELECT COALESCE(SUM(delta_xp), 0) AS total FROM reward_ledger`).get();
@@ -537,11 +567,7 @@ export function recordTaskRunStartReward(taskRunId, taskId, actor, source) {
537
567
  });
538
568
  }
539
569
  export function recordTaskRunProgressRewards(taskRunId, taskId, actor, source, creditedSeconds) {
540
- ensureDefaultRewardRules();
541
- const rule = getRuleByCode("task_run_progress");
542
- const intervalMinutes = Math.max(1, Number(rule?.config.intervalMinutes ?? 10));
543
- const intervalSeconds = intervalMinutes * 60;
544
- const fixedXp = Number(rule?.config.fixedXp ?? 4);
570
+ const { rule, intervalMinutes, intervalSeconds, fixedXp } = getTaskRunProgressRewardCadence();
545
571
  const earnedBuckets = Math.floor(Math.max(0, creditedSeconds) / intervalSeconds);
546
572
  if (earnedBuckets <= 0) {
547
573
  return [];
@@ -593,6 +619,55 @@ export function recordTaskRunProgressRewards(taskRunId, taskId, actor, source, c
593
619
  }
594
620
  return rewards;
595
621
  }
622
+ export function recordWorkAdjustmentReward(input) {
623
+ const { rule, intervalMinutes, intervalSeconds, fixedXp } = getTaskRunProgressRewardCadence();
624
+ const entityType = workAdjustmentEntityTypeSchema.parse(input.entityType);
625
+ const previousBuckets = Math.floor(Math.max(0, input.previousCreditedSeconds) / intervalSeconds);
626
+ const nextBuckets = Math.floor(Math.max(0, input.nextCreditedSeconds) / intervalSeconds);
627
+ const bucketDelta = nextBuckets - previousBuckets;
628
+ if (bucketDelta === 0) {
629
+ return null;
630
+ }
631
+ const deltaXp = bucketDelta * fixedXp;
632
+ const direction = bucketDelta > 0 ? "added" : "removed";
633
+ const appliedMinutes = Math.abs(input.appliedDeltaMinutes);
634
+ const eventLog = recordEventLog({
635
+ eventKind: "reward.work_adjustment",
636
+ entityType,
637
+ entityId: input.entityId,
638
+ actor: input.actor ?? null,
639
+ source: input.source,
640
+ metadata: {
641
+ adjustmentId: input.adjustmentId,
642
+ requestedDeltaMinutes: input.requestedDeltaMinutes,
643
+ appliedDeltaMinutes: input.appliedDeltaMinutes,
644
+ bucketDelta,
645
+ deltaXp
646
+ }
647
+ });
648
+ return insertLedgerEvent({
649
+ ruleId: rule?.id ?? null,
650
+ eventLogId: eventLog.id,
651
+ entityType,
652
+ entityId: input.entityId,
653
+ actor: input.actor ?? null,
654
+ source: input.source,
655
+ deltaXp,
656
+ reasonTitle: bucketDelta > 0 ? "Manual work minutes added" : "Manual work minutes removed",
657
+ 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
+ reversibleGroup: `work_adjustment:${entityType}:${input.entityId}:${input.adjustmentId}`,
659
+ metadata: {
660
+ adjustmentId: input.adjustmentId,
661
+ requestedDeltaMinutes: input.requestedDeltaMinutes,
662
+ appliedDeltaMinutes: input.appliedDeltaMinutes,
663
+ previousCreditedSeconds: input.previousCreditedSeconds,
664
+ nextCreditedSeconds: input.nextCreditedSeconds,
665
+ bucketDelta,
666
+ intervalMinutes,
667
+ rewardCategory: "manual_work_adjustment"
668
+ }
669
+ });
670
+ }
596
671
  export function recordSessionEvent(input, activity, now = new Date()) {
597
672
  ensureDefaultRewardRules();
598
673
  const sessionEvent = sessionEventSchema.parse({
@@ -695,6 +770,39 @@ export function recordHabitCheckInReward(habit, status, dateKey, activity) {
695
770
  }
696
771
  });
697
772
  }
773
+ export function recordWeeklyReviewCompletionReward(input, activity) {
774
+ ensureDefaultRewardRules();
775
+ const rule = getRuleByCode("weekly_review_completed");
776
+ const deltaXp = Math.max(0, Number(rule?.config.fixedXp ?? input.rewardXp));
777
+ const eventLog = recordEventLog({
778
+ eventKind: "reward.weekly_review_completed",
779
+ entityType: "system",
780
+ entityId: input.weekKey,
781
+ actor: activity.actor ?? null,
782
+ source: activity.source,
783
+ metadata: {
784
+ weekKey: input.weekKey,
785
+ windowLabel: input.windowLabel,
786
+ deltaXp
787
+ }
788
+ });
789
+ return insertLedgerEvent({
790
+ ruleId: rule?.id ?? null,
791
+ eventLogId: eventLog.id,
792
+ entityType: "system",
793
+ entityId: input.weekKey,
794
+ actor: activity.actor ?? null,
795
+ source: activity.source,
796
+ deltaXp,
797
+ reasonTitle: rule?.title ?? "Weekly review completed",
798
+ reasonSummary: `Closed the review for ${input.windowLabel}.`,
799
+ reversibleGroup: `weekly_review_completed:${input.weekKey}`,
800
+ metadata: {
801
+ weekKey: input.weekKey,
802
+ windowLabel: input.windowLabel
803
+ }
804
+ });
805
+ }
698
806
  export function listSessionEvents(limit = 50) {
699
807
  const rows = getDatabase()
700
808
  .prepare(`SELECT id, session_id, event_type, actor, source, metrics_json, created_at