forge-openclaw-plugin 0.2.60 → 0.2.65
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.
- package/README.md +121 -51
- package/dist/assets/{board-B1V3M__K.js → board-DUwMfZvN.js} +1 -1
- package/dist/assets/index-B9VOpR7r.css +1 -0
- package/dist/assets/index-DoHjjze2.js +90 -0
- package/dist/assets/{motion-CltSTItx.js → motion-Crg3QyXD.js} +1 -1
- package/dist/assets/{table-B-VrSFx8.js → table-CTlDeYRs.js} +1 -1
- package/dist/assets/{ui-DUqM4jkt.js → ui-CJPaElbj.js} +1 -1
- package/dist/assets/{vendor-C0otBhgu.js → vendor-BdrT2htV.js} +217 -207
- package/dist/companion-iroh/darwin-arm64/forge-companion-iroh +0 -0
- package/dist/companion-iroh/darwin-x64/forge-companion-iroh +0 -0
- package/dist/companion-iroh/linux-x64/forge-companion-iroh +0 -0
- package/dist/companion-iroh-src/Cargo.lock +4559 -0
- package/dist/companion-iroh-src/Cargo.toml +37 -0
- package/dist/companion-iroh-src/src/lib.rs +279 -0
- package/dist/companion-iroh-src/src/main.rs +478 -0
- package/dist/companion-iroh-src/src/protocol.rs +129 -0
- package/dist/gamification-previews/dark-fantasy-item-trophy-tasks-anvil-marathon.webp +0 -0
- package/dist/gamification-previews/dark-fantasy-item-trophy-xp-levels-the-first-heat.webp +0 -0
- package/dist/gamification-previews/dark-fantasy-item-unlock-streaks-molten-crown-fire.webp +0 -0
- package/dist/gamification-previews/dark-fantasy-mascot.webp +0 -0
- package/dist/gamification-previews/dramatic-smithie-item-trophy-tasks-anvil-marathon.webp +0 -0
- package/dist/gamification-previews/dramatic-smithie-item-trophy-xp-levels-the-first-heat.webp +0 -0
- package/dist/gamification-previews/dramatic-smithie-item-unlock-streaks-molten-crown-fire.webp +0 -0
- package/dist/gamification-previews/dramatic-smithie-mascot.webp +0 -0
- package/dist/gamification-previews/mind-locksmith-item-trophy-tasks-anvil-marathon.webp +0 -0
- package/dist/gamification-previews/mind-locksmith-item-trophy-xp-levels-the-first-heat.webp +0 -0
- package/dist/gamification-previews/mind-locksmith-item-unlock-streaks-molten-crown-fire.webp +0 -0
- package/dist/gamification-previews/mind-locksmith-mascot.webp +0 -0
- package/dist/index.html +7 -7
- package/dist/openclaw/parity.js +27 -0
- package/dist/openclaw/plugin-entry-shared.js +2 -2
- package/dist/openclaw/plugin-sdk-types.d.ts +2 -1
- package/dist/openclaw/routes.d.ts +4 -0
- package/dist/openclaw/routes.js +112 -3
- package/dist/openclaw/tools.js +32 -4
- package/dist/server/server/migrations/059_data_backup_retention.sql +2 -0
- package/dist/server/server/src/app.js +288 -61
- package/dist/server/server/src/data-management-types.js +2 -0
- package/dist/server/server/src/discovery-advertiser.js +13 -0
- package/dist/server/server/src/health.js +58 -3
- package/dist/server/server/src/movement.js +16 -1
- package/dist/server/server/src/openapi.js +410 -9
- package/dist/server/server/src/repositories/rewards.js +60 -0
- package/dist/server/server/src/services/companion-iroh.js +425 -0
- package/dist/server/server/src/services/data-management.js +32 -2
- package/dist/server/server/src/services/doctor.js +762 -0
- package/dist/server/server/src/services/gamification.js +75 -3
- package/dist/server/server/src/services/life-force.js +166 -25
- package/dist/server/server/src/web.js +88 -12
- package/dist/server/src/lib/api.js +9 -0
- package/dist/server/src/lib/gamification-catalog.js +1 -1
- package/openclaw.plugin.json +85 -3
- package/package.json +10 -6
- package/server/migrations/059_data_backup_retention.sql +2 -0
- package/skills/forge-openclaw/SKILL.md +80 -19
- package/skills/forge-openclaw/entity_conversation_playbooks.md +283 -25
- package/skills/forge-openclaw/psyche_entity_playbooks.md +82 -0
- package/dist/assets/index-BwKAPo98.css +0 -1
- package/dist/assets/index-Dy7c-dRY.js +0 -90
|
@@ -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
|
+
}
|