forge-openclaw-plugin 0.2.59 → 0.2.61

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 (62) hide show
  1. package/README.md +93 -46
  2. package/dist/assets/{board-BAxNp060.js → board-DThHV1D8.js} +1 -2
  3. package/dist/assets/index-7gvVCqnV.css +1 -0
  4. package/dist/assets/index-_Cn6Prym.js +90 -0
  5. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js +0 -1
  6. package/dist/assets/{motion-B9BeeSmV.js → motion-BtTJtHCw.js} +1 -2
  7. package/dist/assets/{table-kY1tUKX5.js → table-Bnw6pcwN.js} +1 -2
  8. package/dist/assets/{ui-FaWfAb5Q.js → ui-CnVxFkj0.js} +1 -2
  9. package/dist/assets/{vendor-CUxVKN94.js → vendor-BgZ3YrRd.js} +212 -208
  10. package/dist/gamification-previews/dark-fantasy-item-trophy-tasks-anvil-marathon.webp +0 -0
  11. package/dist/gamification-previews/dark-fantasy-item-trophy-xp-levels-the-first-heat.webp +0 -0
  12. package/dist/gamification-previews/dark-fantasy-item-unlock-streaks-molten-crown-fire.webp +0 -0
  13. package/dist/gamification-previews/dark-fantasy-mascot.webp +0 -0
  14. package/dist/gamification-previews/dramatic-smithie-item-trophy-tasks-anvil-marathon.webp +0 -0
  15. package/dist/gamification-previews/dramatic-smithie-item-trophy-xp-levels-the-first-heat.webp +0 -0
  16. package/dist/gamification-previews/dramatic-smithie-item-unlock-streaks-molten-crown-fire.webp +0 -0
  17. package/dist/gamification-previews/dramatic-smithie-mascot.webp +0 -0
  18. package/dist/gamification-previews/mind-locksmith-item-trophy-tasks-anvil-marathon.webp +0 -0
  19. package/dist/gamification-previews/mind-locksmith-item-trophy-xp-levels-the-first-heat.webp +0 -0
  20. package/dist/gamification-previews/mind-locksmith-item-unlock-streaks-molten-crown-fire.webp +0 -0
  21. package/dist/gamification-previews/mind-locksmith-mascot.webp +0 -0
  22. package/dist/index.html +7 -7
  23. package/dist/openclaw/parity.js +27 -0
  24. package/dist/openclaw/plugin-entry-shared.js +2 -2
  25. package/dist/openclaw/plugin-sdk-types.d.ts +2 -1
  26. package/dist/openclaw/routes.d.ts +4 -0
  27. package/dist/openclaw/routes.js +112 -3
  28. package/dist/openclaw/tools.js +32 -4
  29. package/dist/server/server/migrations/058_gamification_theme_preference.sql +1 -1
  30. package/dist/server/server/migrations/059_data_backup_retention.sql +2 -0
  31. package/dist/server/server/src/app.js +152 -43
  32. package/dist/server/server/src/data-management-types.js +2 -0
  33. package/dist/server/server/src/health.js +40 -0
  34. package/dist/server/server/src/openapi.js +398 -7
  35. package/dist/server/server/src/repositories/rewards.js +60 -0
  36. package/dist/server/server/src/repositories/settings.js +1 -1
  37. package/dist/server/server/src/services/data-management.js +32 -2
  38. package/dist/server/server/src/services/doctor.js +762 -0
  39. package/dist/server/server/src/services/gamification-assets.js +231 -0
  40. package/dist/server/server/src/services/gamification.js +75 -3
  41. package/dist/server/server/src/types.js +1 -1
  42. package/dist/server/server/src/web.js +7 -104
  43. package/dist/server/src/lib/api.js +18 -0
  44. package/dist/server/src/lib/gamification-catalog.js +1 -1
  45. package/dist/server/src/lib/schemas.js +1 -1
  46. package/openclaw.plugin.json +85 -3
  47. package/package.json +8 -4
  48. package/server/migrations/058_gamification_theme_preference.sql +1 -1
  49. package/server/migrations/059_data_backup_retention.sql +2 -0
  50. package/skills/forge-openclaw/SKILL.md +38 -19
  51. package/skills/forge-openclaw/entity_conversation_playbooks.md +66 -8
  52. package/skills/forge-openclaw/psyche_entity_playbooks.md +23 -0
  53. package/dist/assets/board-BAxNp060.js.map +0 -1
  54. package/dist/assets/index-B5Yt4i07.css +0 -1
  55. package/dist/assets/index-NZwTuYPs.js +0 -91
  56. package/dist/assets/index-NZwTuYPs.js.map +0 -1
  57. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js.map +0 -1
  58. package/dist/assets/motion-B9BeeSmV.js.map +0 -1
  59. package/dist/assets/table-kY1tUKX5.js.map +0 -1
  60. package/dist/assets/ui-FaWfAb5Q.js.map +0 -1
  61. package/dist/assets/vendor-CUxVKN94.js.map +0 -1
  62. package/dist/gamification/sprites.zip +0 -0
@@ -0,0 +1,762 @@
1
+ import { access, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { getDatabase, runInTransaction } from "../db.js";
5
+ import { GAMIFICATION_CATALOG } from "../../../src/lib/gamification-catalog.js";
6
+ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
7
+ const migrationsDir = path.join(projectRoot, "server", "migrations");
8
+ const safeIntegrityRefreshFix = {
9
+ id: "settings.integrity.refresh",
10
+ kind: "safe_auto_fix",
11
+ title: "Refresh stored integrity audit",
12
+ description: "Update the legacy Settings integrity score and last audit timestamp from the current Doctor result.",
13
+ requiresConfirmation: true
14
+ };
15
+ function errorMessage(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+ function toCount(row) {
19
+ if (typeof row !== "object" || row === null || !("count" in row)) {
20
+ return 0;
21
+ }
22
+ const count = row.count;
23
+ return typeof count === "number" ? count : Number(count) || 0;
24
+ }
25
+ function tableExists(tableName) {
26
+ const row = getDatabase()
27
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1")
28
+ .get(tableName);
29
+ return Boolean(row);
30
+ }
31
+ function countRows(sql, params = []) {
32
+ return toCount(getDatabase().prepare(sql).get(...params));
33
+ }
34
+ function check(input) {
35
+ const severity = input.severity ?? (input.passed ? "info" : "warning");
36
+ return {
37
+ id: input.id,
38
+ group: input.group,
39
+ title: input.title,
40
+ status: input.passed ? "pass" : severity === "error" ? "fail" : "warn",
41
+ severity,
42
+ summary: input.summary,
43
+ evidence: input.evidence ?? [],
44
+ affectedCount: input.affectedCount,
45
+ fix: input.fix
46
+ };
47
+ }
48
+ function skippedCheck(input) {
49
+ return {
50
+ ...input,
51
+ status: "skipped",
52
+ severity: "info",
53
+ evidence: [],
54
+ affectedCount: 0
55
+ };
56
+ }
57
+ function safeCountCheck(input) {
58
+ try {
59
+ const count = countRows(input.sql);
60
+ return check({
61
+ id: input.id,
62
+ group: input.group,
63
+ title: input.title,
64
+ passed: count === 0,
65
+ severity: input.severity,
66
+ summary: input.summary(count),
67
+ evidence: input.evidence?.(count),
68
+ affectedCount: count,
69
+ fix: input.fix
70
+ });
71
+ }
72
+ catch (error) {
73
+ return check({
74
+ id: input.id,
75
+ group: input.group,
76
+ title: input.title,
77
+ passed: false,
78
+ severity: "error",
79
+ summary: `Doctor could not run this check: ${errorMessage(error)}`,
80
+ affectedCount: 1
81
+ });
82
+ }
83
+ }
84
+ async function buildStorageChecks() {
85
+ const checks = [];
86
+ try {
87
+ const integrityRows = getDatabase().prepare("PRAGMA integrity_check").all();
88
+ const messages = integrityRows
89
+ .map((row) => Object.values(row)[0])
90
+ .filter((value) => typeof value === "string");
91
+ const passed = messages.length === 1 && messages[0] === "ok";
92
+ checks.push(check({
93
+ id: "storage.sqlite.integrity",
94
+ group: "Storage",
95
+ title: "SQLite integrity",
96
+ passed,
97
+ severity: passed ? "info" : "error",
98
+ summary: passed
99
+ ? "SQLite integrity_check passed."
100
+ : "SQLite integrity_check reported database corruption or structural errors.",
101
+ evidence: passed ? [] : messages.slice(0, 8),
102
+ affectedCount: passed ? 0 : messages.length
103
+ }));
104
+ }
105
+ catch (error) {
106
+ checks.push(check({
107
+ id: "storage.sqlite.integrity",
108
+ group: "Storage",
109
+ title: "SQLite integrity",
110
+ passed: false,
111
+ severity: "error",
112
+ summary: `SQLite integrity_check failed to run: ${errorMessage(error)}`,
113
+ affectedCount: 1
114
+ }));
115
+ }
116
+ try {
117
+ const rows = getDatabase().prepare("PRAGMA foreign_key_check").all();
118
+ checks.push(check({
119
+ id: "storage.sqlite.foreign_keys",
120
+ group: "Storage",
121
+ title: "SQLite foreign keys",
122
+ passed: rows.length === 0,
123
+ severity: rows.length === 0 ? "info" : "error",
124
+ summary: rows.length === 0
125
+ ? "SQLite foreign_key_check passed."
126
+ : `${rows.length} foreign key violation${rows.length === 1 ? "" : "s"} found in SQLite.`,
127
+ evidence: rows
128
+ .slice(0, 8)
129
+ .map((row) => JSON.stringify(row)),
130
+ affectedCount: rows.length
131
+ }));
132
+ }
133
+ catch (error) {
134
+ checks.push(check({
135
+ id: "storage.sqlite.foreign_keys",
136
+ group: "Storage",
137
+ title: "SQLite foreign keys",
138
+ passed: false,
139
+ severity: "error",
140
+ summary: `SQLite foreign_key_check failed to run: ${errorMessage(error)}`,
141
+ affectedCount: 1
142
+ }));
143
+ }
144
+ const requiredTables = [
145
+ "app_settings",
146
+ "users",
147
+ "goals",
148
+ "projects",
149
+ "strategies",
150
+ "tasks",
151
+ "entity_owners",
152
+ "entity_assignments",
153
+ "notes",
154
+ "habits",
155
+ "calendar_events",
156
+ "task_runs",
157
+ "reward_ledger",
158
+ "gamification_daily_activity",
159
+ "gamification_item_unlocks",
160
+ "gamification_equipment",
161
+ "wiki_spaces",
162
+ "wiki_link_edges",
163
+ "agent_runtime_sessions"
164
+ ];
165
+ const missingTables = requiredTables.filter((table) => !tableExists(table));
166
+ checks.push(check({
167
+ id: "storage.schema.required_tables",
168
+ group: "Storage",
169
+ title: "Required schema tables",
170
+ passed: missingTables.length === 0,
171
+ severity: missingTables.length === 0 ? "info" : "error",
172
+ summary: missingTables.length === 0
173
+ ? "All required Forge tables are present."
174
+ : `${missingTables.length} required table${missingTables.length === 1 ? "" : "s"} missing from the database.`,
175
+ evidence: missingTables,
176
+ affectedCount: missingTables.length
177
+ }));
178
+ try {
179
+ const files = (await readdir(migrationsDir))
180
+ .filter((file) => file.endsWith(".sql"))
181
+ .sort();
182
+ const applied = new Set(getDatabase()
183
+ .prepare("SELECT id FROM migrations")
184
+ .all().map((row) => row.id));
185
+ const missing = files.filter((file) => !applied.has(file));
186
+ checks.push(check({
187
+ id: "storage.schema.migrations",
188
+ group: "Storage",
189
+ title: "Applied migrations",
190
+ passed: missing.length === 0,
191
+ severity: missing.length === 0 ? "info" : "error",
192
+ summary: missing.length === 0
193
+ ? `${files.length} migration${files.length === 1 ? "" : "s"} applied.`
194
+ : `${missing.length} migration${missing.length === 1 ? "" : "s"} not recorded as applied.`,
195
+ evidence: missing.slice(0, 12),
196
+ affectedCount: missing.length
197
+ }));
198
+ }
199
+ catch (error) {
200
+ checks.push(check({
201
+ id: "storage.schema.migrations",
202
+ group: "Storage",
203
+ title: "Applied migrations",
204
+ passed: false,
205
+ severity: "error",
206
+ summary: `Doctor could not compare migration files: ${errorMessage(error)}`,
207
+ affectedCount: 1
208
+ }));
209
+ }
210
+ return checks;
211
+ }
212
+ function entityReferenceChecks() {
213
+ const checks = [];
214
+ checks.push(safeCountCheck({
215
+ id: "entities.owners.missing_users",
216
+ group: "Entities",
217
+ title: "Entity owner users",
218
+ sql: `SELECT COUNT(*) AS count
219
+ FROM entity_owners
220
+ LEFT JOIN users ON users.id = entity_owners.user_id
221
+ WHERE users.id IS NULL`,
222
+ summary: (count) => count === 0
223
+ ? "All entity owners point to existing users."
224
+ : `${count} entity owner record${count === 1 ? "" : "s"} point to missing users.`,
225
+ severity: "warning"
226
+ }), safeCountCheck({
227
+ id: "entities.assignments.missing_users",
228
+ group: "Entities",
229
+ title: "Entity assignee users",
230
+ sql: `SELECT COUNT(*) AS count
231
+ FROM entity_assignments
232
+ LEFT JOIN users ON users.id = entity_assignments.user_id
233
+ WHERE users.id IS NULL`,
234
+ summary: (count) => count === 0
235
+ ? "All entity assignments point to existing users."
236
+ : `${count} assignment record${count === 1 ? "" : "s"} point to missing users.`,
237
+ severity: "warning"
238
+ }), safeCountCheck({
239
+ id: "entities.projects.missing_goals",
240
+ group: "Entities",
241
+ title: "Project goal links",
242
+ sql: `SELECT COUNT(*) AS count
243
+ FROM projects
244
+ LEFT JOIN goals ON goals.id = projects.goal_id
245
+ WHERE goals.id IS NULL`,
246
+ summary: (count) => count === 0
247
+ ? "All projects point to existing goals."
248
+ : `${count} project${count === 1 ? "" : "s"} point to missing goals.`,
249
+ severity: "warning"
250
+ }), safeCountCheck({
251
+ id: "entities.tasks.missing_projects",
252
+ group: "Entities",
253
+ title: "Task project links",
254
+ sql: `SELECT COUNT(*) AS count
255
+ FROM tasks
256
+ LEFT JOIN projects ON projects.id = tasks.project_id
257
+ WHERE tasks.project_id IS NOT NULL AND projects.id IS NULL`,
258
+ summary: (count) => count === 0
259
+ ? "All task project links resolve."
260
+ : `${count} task${count === 1 ? "" : "s"} point to missing projects.`,
261
+ severity: "warning"
262
+ }), safeCountCheck({
263
+ id: "entities.tasks.missing_goals",
264
+ group: "Entities",
265
+ title: "Task goal links",
266
+ sql: `SELECT COUNT(*) AS count
267
+ FROM tasks
268
+ LEFT JOIN goals ON goals.id = tasks.goal_id
269
+ WHERE tasks.goal_id IS NOT NULL AND goals.id IS NULL`,
270
+ summary: (count) => count === 0
271
+ ? "All task goal links resolve."
272
+ : `${count} task${count === 1 ? "" : "s"} point to missing goals.`,
273
+ severity: "warning"
274
+ }));
275
+ const ownedEntityChecks = [
276
+ ["goal", "goals"],
277
+ ["project", "projects"],
278
+ ["strategy", "strategies"],
279
+ ["task", "tasks"],
280
+ ["tag", "tags"],
281
+ ["habit", "habits"],
282
+ ["note", "notes"]
283
+ ];
284
+ for (const [entityType, tableName] of ownedEntityChecks) {
285
+ if (!tableExists(tableName))
286
+ continue;
287
+ checks.push(safeCountCheck({
288
+ id: `entities.owners.missing_${entityType}`,
289
+ group: "Entities",
290
+ title: `${entityType} owner targets`,
291
+ sql: `SELECT COUNT(*) AS count
292
+ FROM entity_owners
293
+ LEFT JOIN ${tableName} target ON target.id = entity_owners.entity_id
294
+ WHERE entity_owners.entity_type = '${entityType}' AND target.id IS NULL`,
295
+ summary: (count) => count === 0
296
+ ? `All ${entityType} owner rows point to existing records.`
297
+ : `${count} ${entityType} owner row${count === 1 ? " points" : "s point"} to missing records.`,
298
+ severity: "warning"
299
+ }));
300
+ }
301
+ return checks;
302
+ }
303
+ function hierarchyChecks() {
304
+ if (!tableExists("tasks")) {
305
+ return [
306
+ skippedCheck({
307
+ id: "entities.hierarchy.tasks",
308
+ group: "Hierarchy",
309
+ title: "Work item hierarchy",
310
+ summary: "Task table is missing, so hierarchy checks could not run."
311
+ })
312
+ ];
313
+ }
314
+ return [
315
+ safeCountCheck({
316
+ id: "entities.hierarchy.missing_parents",
317
+ group: "Hierarchy",
318
+ title: "Work item parents",
319
+ sql: `SELECT COUNT(*) AS count
320
+ FROM tasks child
321
+ LEFT JOIN tasks parent ON parent.id = child.parent_task_id
322
+ WHERE child.parent_task_id IS NOT NULL AND parent.id IS NULL`,
323
+ summary: (count) => count === 0
324
+ ? "All parent work-item links resolve."
325
+ : `${count} work item${count === 1 ? "" : "s"} point to missing parents.`,
326
+ severity: "warning"
327
+ }),
328
+ safeCountCheck({
329
+ id: "entities.hierarchy.self_parent",
330
+ group: "Hierarchy",
331
+ title: "Self-parented work items",
332
+ sql: "SELECT COUNT(*) AS count FROM tasks WHERE parent_task_id = id",
333
+ summary: (count) => count === 0
334
+ ? "No work items point to themselves as parent."
335
+ : `${count} work item${count === 1 ? "" : "s"} point to themselves as parent.`,
336
+ severity: "error"
337
+ }),
338
+ safeCountCheck({
339
+ id: "entities.hierarchy.issue_project",
340
+ group: "Hierarchy",
341
+ title: "Issue project links",
342
+ sql: "SELECT COUNT(*) AS count FROM tasks WHERE level = 'issue' AND project_id IS NULL",
343
+ summary: (count) => count === 0
344
+ ? "Every issue is linked to a project."
345
+ : `${count} issue${count === 1 ? "" : "s"} are not linked to a project.`,
346
+ severity: "info"
347
+ }),
348
+ safeCountCheck({
349
+ id: "entities.hierarchy.task_parent_level",
350
+ group: "Hierarchy",
351
+ title: "Task parent levels",
352
+ sql: `SELECT COUNT(*) AS count
353
+ FROM tasks child
354
+ JOIN tasks parent ON parent.id = child.parent_task_id
355
+ WHERE child.level = 'task' AND parent.level != 'issue'`,
356
+ summary: (count) => count === 0
357
+ ? "All task parents are issues when a parent is set."
358
+ : `${count} task${count === 1 ? "" : "s"} have a parent that is not an issue.`,
359
+ severity: "warning"
360
+ }),
361
+ safeCountCheck({
362
+ id: "entities.hierarchy.subtask_parent_level",
363
+ group: "Hierarchy",
364
+ title: "Subtask parent levels",
365
+ sql: `SELECT COUNT(*) AS count
366
+ FROM tasks child
367
+ LEFT JOIN tasks parent ON parent.id = child.parent_task_id
368
+ WHERE child.level = 'subtask' AND (parent.id IS NULL OR parent.level != 'task')`,
369
+ summary: (count) => count === 0
370
+ ? "All subtasks sit under tasks."
371
+ : `${count} subtask${count === 1 ? "" : "s"} are missing a task parent.`,
372
+ severity: "warning"
373
+ }),
374
+ safeCountCheck({
375
+ id: "entities.hierarchy.project_mismatch",
376
+ group: "Hierarchy",
377
+ title: "Parent/project consistency",
378
+ sql: `SELECT COUNT(*) AS count
379
+ FROM tasks child
380
+ JOIN tasks parent ON parent.id = child.parent_task_id
381
+ WHERE child.project_id IS NOT NULL
382
+ AND parent.project_id IS NOT NULL
383
+ AND child.project_id != parent.project_id`,
384
+ summary: (count) => count === 0
385
+ ? "Child work items stay inside the same project as their parent."
386
+ : `${count} work item${count === 1 ? "" : "s"} have a different project than their parent.`,
387
+ severity: "warning"
388
+ })
389
+ ];
390
+ }
391
+ function strategyJsonChecks() {
392
+ if (!tableExists("strategies")) {
393
+ return [
394
+ skippedCheck({
395
+ id: "entities.strategies.json",
396
+ group: "Entities",
397
+ title: "Strategy JSON fields",
398
+ summary: "Strategy table is missing, so strategy JSON checks could not run."
399
+ })
400
+ ];
401
+ }
402
+ const goalIds = new Set(getDatabase().prepare("SELECT id FROM goals").all().map((row) => row.id));
403
+ const projectIds = new Set(getDatabase().prepare("SELECT id FROM projects").all().map((row) => row.id));
404
+ const rows = getDatabase()
405
+ .prepare(`SELECT id, target_goal_ids_json, target_project_ids_json, linked_entities_json, graph_json
406
+ FROM strategies`)
407
+ .all();
408
+ let invalidJson = 0;
409
+ let missingGoalRefs = 0;
410
+ let missingProjectRefs = 0;
411
+ for (const row of rows) {
412
+ try {
413
+ const goals = JSON.parse(row.target_goal_ids_json);
414
+ if (Array.isArray(goals)) {
415
+ missingGoalRefs += goals.filter((id) => typeof id === "string" && !goalIds.has(id)).length;
416
+ }
417
+ const projects = JSON.parse(row.target_project_ids_json);
418
+ if (Array.isArray(projects)) {
419
+ missingProjectRefs += projects.filter((id) => typeof id === "string" && !projectIds.has(id)).length;
420
+ }
421
+ JSON.parse(row.linked_entities_json);
422
+ JSON.parse(row.graph_json);
423
+ }
424
+ catch {
425
+ invalidJson += 1;
426
+ }
427
+ }
428
+ return [
429
+ check({
430
+ id: "entities.strategies.json",
431
+ group: "Entities",
432
+ title: "Strategy JSON fields",
433
+ passed: invalidJson === 0,
434
+ severity: "warning",
435
+ summary: invalidJson === 0
436
+ ? "All strategy JSON fields parse cleanly."
437
+ : `${invalidJson} strateg${invalidJson === 1 ? "y has" : "ies have"} invalid JSON fields.`,
438
+ affectedCount: invalidJson
439
+ }),
440
+ check({
441
+ id: "entities.strategies.goal_refs",
442
+ group: "Entities",
443
+ title: "Strategy goal references",
444
+ passed: missingGoalRefs === 0,
445
+ severity: "warning",
446
+ summary: missingGoalRefs === 0
447
+ ? "All strategy target goal references resolve."
448
+ : `${missingGoalRefs} strategy goal reference${missingGoalRefs === 1 ? "" : "s"} point to missing goals.`,
449
+ affectedCount: missingGoalRefs
450
+ }),
451
+ check({
452
+ id: "entities.strategies.project_refs",
453
+ group: "Entities",
454
+ title: "Strategy project references",
455
+ passed: missingProjectRefs === 0,
456
+ severity: "warning",
457
+ summary: missingProjectRefs === 0
458
+ ? "All strategy target project references resolve."
459
+ : `${missingProjectRefs} strategy project reference${missingProjectRefs === 1 ? "" : "s"} point to missing projects.`,
460
+ affectedCount: missingProjectRefs
461
+ })
462
+ ];
463
+ }
464
+ function rewardAndGamificationChecks(settings) {
465
+ const checks = [];
466
+ const catalogItemIds = new Set(GAMIFICATION_CATALOG.map((item) => item.id));
467
+ const equipmentItemIds = new Set(GAMIFICATION_CATALOG.filter((item) => item.kind === "unlock").map((item) => item.id));
468
+ checks.push(safeCountCheck({
469
+ id: "rewards.rules.missing",
470
+ group: "Rewards",
471
+ title: "Reward ledger rules",
472
+ sql: `SELECT COUNT(*) AS count
473
+ FROM reward_ledger
474
+ LEFT JOIN reward_rules ON reward_rules.id = reward_ledger.rule_id
475
+ WHERE reward_ledger.rule_id IS NOT NULL AND reward_rules.id IS NULL`,
476
+ summary: (count) => count === 0
477
+ ? "All reward ledger rule references resolve."
478
+ : `${count} reward ledger row${count === 1 ? "" : "s"} point to missing rules.`,
479
+ severity: "warning"
480
+ }), safeCountCheck({
481
+ id: "rewards.entity_creation.duplicates",
482
+ group: "Rewards",
483
+ title: "Entity creation XP duplicates",
484
+ sql: `SELECT COUNT(*) AS count
485
+ FROM (
486
+ SELECT reversible_group
487
+ FROM reward_ledger
488
+ WHERE reversible_group LIKE 'entity_created:%'
489
+ AND reversed_by_reward_id IS NULL
490
+ GROUP BY reversible_group
491
+ HAVING COUNT(*) > 1
492
+ ) duplicates`,
493
+ summary: (count) => count === 0
494
+ ? "Entity creation XP has no duplicate active reversible groups."
495
+ : `${count} entity creation reward group${count === 1 ? "" : "s"} have duplicate active XP rows.`,
496
+ severity: "warning"
497
+ }), safeCountCheck({
498
+ id: "rewards.daily_activity.users",
499
+ group: "Rewards",
500
+ title: "Daily activity users",
501
+ sql: `SELECT COUNT(*) AS count
502
+ FROM gamification_daily_activity
503
+ LEFT JOIN users ON users.id = gamification_daily_activity.user_id
504
+ WHERE users.id IS NULL`,
505
+ summary: (count) => count === 0
506
+ ? "All gamification daily activity rows point to existing users."
507
+ : `${count} daily activity row${count === 1 ? "" : "s"} point to missing users.`,
508
+ severity: "warning"
509
+ }));
510
+ const staleUnlockRows = tableExists("gamification_item_unlocks")
511
+ ? getDatabase()
512
+ .prepare("SELECT item_id FROM gamification_item_unlocks")
513
+ .all().filter((row) => !catalogItemIds.has(row.item_id)).length
514
+ : 0;
515
+ checks.push(check({
516
+ id: "rewards.gamification.stale_unlocks",
517
+ group: "Rewards",
518
+ title: "Gamification unlock catalog",
519
+ passed: staleUnlockRows === 0,
520
+ severity: "info",
521
+ summary: staleUnlockRows === 0
522
+ ? "All gamification unlock rows point to the current catalog."
523
+ : `${staleUnlockRows} old gamification unlock row${staleUnlockRows === 1 ? "" : "s"} are kept for audit but no longer match the current catalog.`,
524
+ affectedCount: staleUnlockRows
525
+ }));
526
+ const equipmentRows = tableExists("gamification_equipment")
527
+ ? getDatabase()
528
+ .prepare(`SELECT selected_mascot_skin, selected_hud_treatment, selected_streak_effect,
529
+ selected_trophy_shelf, selected_celebration_variant
530
+ FROM gamification_equipment`)
531
+ .all()
532
+ : [];
533
+ const staleEquipment = equipmentRows.reduce((count, row) => {
534
+ return (count +
535
+ Object.values(row).filter((value) => typeof value === "string" && !equipmentItemIds.has(value)).length);
536
+ }, 0);
537
+ checks.push(check({
538
+ id: "rewards.gamification.equipment",
539
+ group: "Rewards",
540
+ title: "Gamification equipment catalog",
541
+ passed: staleEquipment === 0,
542
+ severity: "warning",
543
+ summary: staleEquipment === 0
544
+ ? "Selected gamification equipment points to current unlock catalog items."
545
+ : `${staleEquipment} selected equipment reference${staleEquipment === 1 ? "" : "s"} point to removed catalog items.`,
546
+ affectedCount: staleEquipment
547
+ }));
548
+ return checks.concat(check({
549
+ id: "settings.integrity.stored_score",
550
+ group: "Settings",
551
+ title: "Stored integrity score",
552
+ passed: true,
553
+ severity: "info",
554
+ summary: `The legacy Settings score is ${settings.security.integrityScore}%; Doctor computes the live score from real checks.`,
555
+ affectedCount: settings.security.integrityScore,
556
+ fix: safeIntegrityRefreshFix
557
+ }));
558
+ }
559
+ async function buildDataRootCheck(runtime) {
560
+ const root = typeof runtime.storageRoot === "string"
561
+ ? runtime.storageRoot
562
+ : typeof runtime.dataDir === "string"
563
+ ? runtime.dataDir
564
+ : null;
565
+ if (!root) {
566
+ return check({
567
+ id: "runtime.data_root",
568
+ group: "Runtime",
569
+ title: "Data root access",
570
+ passed: false,
571
+ severity: "warning",
572
+ summary: "Doctor could not resolve the Forge data root from the runtime payload.",
573
+ affectedCount: 1
574
+ });
575
+ }
576
+ try {
577
+ await access(root);
578
+ return check({
579
+ id: "runtime.data_root",
580
+ group: "Runtime",
581
+ title: "Data root access",
582
+ passed: true,
583
+ summary: `Forge can read the data root at ${root}.`,
584
+ affectedCount: 0
585
+ });
586
+ }
587
+ catch (error) {
588
+ return check({
589
+ id: "runtime.data_root",
590
+ group: "Runtime",
591
+ title: "Data root access",
592
+ passed: false,
593
+ severity: "error",
594
+ summary: `Forge cannot access the data root at ${root}: ${errorMessage(error)}`,
595
+ affectedCount: 1
596
+ });
597
+ }
598
+ }
599
+ function buildIntegrity(now, checks) {
600
+ const issues = checks.filter((entry) => entry.status === "warn" || entry.status === "fail");
601
+ const penalizedIssues = issues.filter((issue) => issue.severity !== "info");
602
+ const warningCount = issues.filter((issue) => issue.severity === "warning").length;
603
+ const errorCount = issues.filter((issue) => issue.severity === "error").length;
604
+ const score = Math.max(0, 100 - errorCount * 12 - warningCount * 2);
605
+ const status = errorCount > 0 ? "critical" : warningCount > 0 ? "warning" : "healthy";
606
+ return {
607
+ score,
608
+ status,
609
+ headline: status === "healthy"
610
+ ? "All active Doctor consistency checks passed."
611
+ : status === "critical"
612
+ ? `${errorCount} critical consistency issue${errorCount === 1 ? "" : "s"} need attention.`
613
+ : `${warningCount} consistency warning${warningCount === 1 ? "" : "s"} need attention.`,
614
+ lastCheckedAt: now,
615
+ issueCount: issues.length,
616
+ warningCount,
617
+ errorCount,
618
+ topIssues: penalizedIssues.slice(0, 5).map((issue) => ({
619
+ id: issue.id,
620
+ severity: issue.severity,
621
+ summary: issue.summary,
622
+ affectedCount: issue.affectedCount
623
+ }))
624
+ };
625
+ }
626
+ function buildWebAppUrl(runtime) {
627
+ const devWebOrigin = typeof runtime.devWebOrigin === "string" ? runtime.devWebOrigin.trim() : "";
628
+ if (devWebOrigin) {
629
+ return devWebOrigin.endsWith("/") ? devWebOrigin : `${devWebOrigin}/`;
630
+ }
631
+ const port = typeof runtime.port === "number" ? runtime.port : 4317;
632
+ const basePath = typeof runtime.basePath === "string" ? runtime.basePath : "/forge/";
633
+ return `http://127.0.0.1:${port}${basePath}`;
634
+ }
635
+ export async function buildForgeDoctorReport(input) {
636
+ const now = new Date().toISOString();
637
+ const healthOk = input.health.ok !== false;
638
+ const checks = [
639
+ check({
640
+ id: "runtime.health",
641
+ group: "Runtime",
642
+ title: "Runtime health",
643
+ passed: healthOk,
644
+ severity: healthOk ? "info" : "error",
645
+ summary: healthOk
646
+ ? "Forge runtime health is green."
647
+ : "Forge runtime health is degraded.",
648
+ affectedCount: healthOk ? 0 : 1
649
+ }),
650
+ await buildDataRootCheck(input.runtime),
651
+ check({
652
+ id: "settings.file.valid",
653
+ group: "Settings",
654
+ title: "forge.json validity",
655
+ passed: input.settingsFile.valid,
656
+ severity: input.settingsFile.valid ? "info" : "error",
657
+ summary: input.settingsFile.valid
658
+ ? "forge.json is valid."
659
+ : `forge.json is invalid at ${input.settingsFile.path}. Forge ignored file precedence until the JSON is repaired or rewritten.`,
660
+ evidence: input.settingsFile.parseError ? [input.settingsFile.parseError] : [],
661
+ affectedCount: input.settingsFile.valid ? 0 : 1
662
+ }),
663
+ check({
664
+ id: "settings.file.sync",
665
+ group: "Settings",
666
+ title: "forge.json sync state",
667
+ passed: input.settingsFile.syncState !== "applied_file_overrides",
668
+ severity: input.settingsFile.syncState === "applied_file_overrides"
669
+ ? "warning"
670
+ : "info",
671
+ summary: input.settingsFile.syncState === "applied_file_overrides"
672
+ ? "forge.json overrode persisted database settings on this run."
673
+ : `forge.json sync state is ${input.settingsFile.syncState}.`,
674
+ evidence: input.settingsFile.overrideKeys.slice(0, 12),
675
+ affectedCount: input.settingsFile.syncState === "applied_file_overrides"
676
+ ? input.settingsFile.overrideKeys.length
677
+ : 0
678
+ })
679
+ ];
680
+ checks.push(...(await buildStorageChecks()));
681
+ checks.push(...entityReferenceChecks());
682
+ checks.push(...hierarchyChecks());
683
+ checks.push(...strategyJsonChecks());
684
+ checks.push(...rewardAndGamificationChecks(input.settings));
685
+ const issues = checks.filter((entry) => entry.status === "warn" || entry.status === "fail");
686
+ const integrity = buildIntegrity(now, checks);
687
+ const fixProposals = checks
688
+ .map((entry) => entry.fix)
689
+ .filter((fix) => Boolean(fix));
690
+ return {
691
+ ok: issues.every((issue) => issue.severity !== "error"),
692
+ now,
693
+ integrity,
694
+ runtime: input.runtime,
695
+ health: input.health,
696
+ settingsFile: input.settingsFile,
697
+ settingsSummary: {
698
+ themePreference: input.settings.themePreference,
699
+ localePreference: input.settings.localePreference,
700
+ operatorName: input.settings.profile.operatorName,
701
+ maxActiveTasks: input.settings.execution.maxActiveTasks,
702
+ timeAccountingMode: input.settings.execution.timeAccountingMode,
703
+ psycheAuthRequired: input.settings.security.psycheAuthRequired,
704
+ webAppUrl: buildWebAppUrl(input.runtime)
705
+ },
706
+ checks,
707
+ issues,
708
+ fixProposals,
709
+ warnings: issues
710
+ .filter((issue) => issue.severity !== "info")
711
+ .map((issue) => issue.summary)
712
+ };
713
+ }
714
+ export function applyForgeDoctorFixes(input, options = {}) {
715
+ const requested = new Set(input.fixIds ?? []);
716
+ const shouldApplyIntegrityRefresh = input.applyAllSafe === true || requested.has(safeIntegrityRefreshFix.id);
717
+ const results = [];
718
+ if (!shouldApplyIntegrityRefresh) {
719
+ return {
720
+ results: requested.size === 0
721
+ ? []
722
+ : [...requested].map((fixId) => ({
723
+ fixId,
724
+ status: "skipped",
725
+ summary: "Forge Doctor does not know this fix id."
726
+ }))
727
+ };
728
+ }
729
+ try {
730
+ runInTransaction(() => {
731
+ getDatabase()
732
+ .prepare(`UPDATE app_settings
733
+ SET integrity_score = ?,
734
+ last_audit_at = ?,
735
+ updated_at = ?
736
+ WHERE id = 1`)
737
+ .run(Math.max(0, Math.min(100, Math.round(options.integrityScore ?? 100))), new Date().toISOString(), new Date().toISOString());
738
+ });
739
+ results.push({
740
+ fixId: safeIntegrityRefreshFix.id,
741
+ status: "applied",
742
+ summary: "Stored Settings integrity audit timestamp was refreshed."
743
+ });
744
+ }
745
+ catch (error) {
746
+ results.push({
747
+ fixId: safeIntegrityRefreshFix.id,
748
+ status: "failed",
749
+ summary: errorMessage(error)
750
+ });
751
+ }
752
+ for (const fixId of requested) {
753
+ if (fixId !== safeIntegrityRefreshFix.id) {
754
+ results.push({
755
+ fixId,
756
+ status: "skipped",
757
+ summary: "Forge Doctor does not know this fix id."
758
+ });
759
+ }
760
+ }
761
+ return { results };
762
+ }