forge-openclaw-plugin 0.2.15 → 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 (67) hide show
  1. package/README.md +39 -4
  2. package/dist/assets/{board-C_m78kvK.js → board-8L3uX7_O.js} +2 -2
  3. package/dist/assets/{board-C_m78kvK.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-CpZvZumD.js → motion-1GAqqi8M.js} +2 -2
  8. package/dist/assets/{motion-CpZvZumD.js.map → motion-1GAqqi8M.js.map} +1 -1
  9. package/dist/assets/{table-DtyXTw03.js → table-DBGlgRjk.js} +2 -2
  10. package/dist/assets/{table-DtyXTw03.js.map → table-DBGlgRjk.js.map} +1 -1
  11. package/dist/assets/{ui-BXbpiKyS.js → ui-iTluWjC4.js} +2 -2
  12. package/dist/assets/{ui-BXbpiKyS.js.map → ui-iTluWjC4.js.map} +1 -1
  13. package/dist/assets/{vendor-QBH6qVEe.js → vendor-BvM2F9Dp.js} +151 -81
  14. package/dist/assets/vendor-BvM2F9Dp.js.map +1 -0
  15. package/dist/assets/{viz-w-IMeueL.js → viz-CNeunkfu.js} +2 -2
  16. package/dist/assets/{viz-w-IMeueL.js.map → viz-CNeunkfu.js.map} +1 -1
  17. package/dist/index.html +8 -8
  18. package/dist/openclaw/local-runtime.js +142 -9
  19. package/dist/openclaw/parity.js +1 -0
  20. package/dist/openclaw/plugin-entry-shared.js +7 -1
  21. package/dist/openclaw/routes.js +7 -0
  22. package/dist/openclaw/tools.js +198 -16
  23. package/dist/server/app.js +2615 -251
  24. package/dist/server/managers/platform/secrets-manager.js +44 -1
  25. package/dist/server/managers/runtime.js +3 -1
  26. package/dist/server/openapi.js +2212 -170
  27. package/dist/server/repositories/calendar.js +1101 -0
  28. package/dist/server/repositories/deleted-entities.js +10 -2
  29. package/dist/server/repositories/habits.js +358 -0
  30. package/dist/server/repositories/notes.js +161 -28
  31. package/dist/server/repositories/projects.js +45 -13
  32. package/dist/server/repositories/rewards.js +176 -6
  33. package/dist/server/repositories/settings.js +47 -5
  34. package/dist/server/repositories/task-runs.js +46 -10
  35. package/dist/server/repositories/tasks.js +25 -9
  36. package/dist/server/repositories/weekly-reviews.js +109 -0
  37. package/dist/server/repositories/work-adjustments.js +105 -0
  38. package/dist/server/services/calendar-runtime.js +1301 -0
  39. package/dist/server/services/context.js +16 -6
  40. package/dist/server/services/dashboard.js +6 -3
  41. package/dist/server/services/entity-crud.js +116 -3
  42. package/dist/server/services/gamification.js +66 -18
  43. package/dist/server/services/insights.js +2 -1
  44. package/dist/server/services/projects.js +32 -8
  45. package/dist/server/services/reviews.js +17 -2
  46. package/dist/server/services/work-time.js +27 -0
  47. package/dist/server/types.js +1069 -45
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +1 -1
  50. package/server/migrations/003_habits.sql +30 -0
  51. package/server/migrations/004_habit_links.sql +8 -0
  52. package/server/migrations/005_habit_psyche_links.sql +24 -0
  53. package/server/migrations/006_work_adjustments.sql +14 -0
  54. package/server/migrations/007_weekly_review_closures.sql +17 -0
  55. package/server/migrations/008_calendar_execution.sql +147 -0
  56. package/server/migrations/009_true_calendar_events.sql +195 -0
  57. package/server/migrations/010_calendar_selection_state.sql +6 -0
  58. package/server/migrations/011_calendar_timezone_backfill.sql +11 -0
  59. package/server/migrations/012_work_block_ranges.sql +7 -0
  60. package/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
  61. package/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
  62. package/skills/forge-openclaw/SKILL.md +130 -10
  63. package/skills/forge-openclaw/cron_jobs.md +395 -0
  64. package/dist/assets/index-BWtLtXwb.js +0 -36
  65. package/dist/assets/index-BWtLtXwb.js.map +0 -1
  66. package/dist/assets/index-Dp5GXY_z.css +0 -1
  67. package/dist/assets/vendor-QBH6qVEe.js.map +0 -1
package/dist/index.html CHANGED
@@ -13,15 +13,15 @@
13
13
  />
14
14
  <link rel="icon" type="image/png" href="/forge/assets/favicon-BCHm9dUV.ico" />
15
15
  <link rel="alternate icon" href="/forge/assets/favicon-BCHm9dUV.ico" />
16
- <script type="module" crossorigin src="/forge/assets/index-BWtLtXwb.js"></script>
17
- <link rel="modulepreload" crossorigin href="/forge/assets/vendor-QBH6qVEe.js">
18
- <link rel="modulepreload" crossorigin href="/forge/assets/motion-CpZvZumD.js">
19
- <link rel="modulepreload" crossorigin href="/forge/assets/ui-BXbpiKyS.js">
20
- <link rel="modulepreload" crossorigin href="/forge/assets/table-DtyXTw03.js">
21
- <link rel="modulepreload" crossorigin href="/forge/assets/viz-w-IMeueL.js">
22
- <link rel="modulepreload" crossorigin href="/forge/assets/board-C_m78kvK.js">
16
+ <script type="module" crossorigin src="/forge/assets/index-Cj1IBH_w.js"></script>
17
+ <link rel="modulepreload" crossorigin href="/forge/assets/vendor-BvM2F9Dp.js">
18
+ <link rel="modulepreload" crossorigin href="/forge/assets/motion-1GAqqi8M.js">
19
+ <link rel="modulepreload" crossorigin href="/forge/assets/ui-iTluWjC4.js">
20
+ <link rel="modulepreload" crossorigin href="/forge/assets/table-DBGlgRjk.js">
21
+ <link rel="modulepreload" crossorigin href="/forge/assets/viz-CNeunkfu.js">
22
+ <link rel="modulepreload" crossorigin href="/forge/assets/board-8L3uX7_O.js">
23
23
  <link rel="stylesheet" crossorigin href="/forge/assets/vendor-CRS-psbw.css">
24
- <link rel="stylesheet" crossorigin href="/forge/assets/index-Dp5GXY_z.css">
24
+ <link rel="stylesheet" crossorigin href="/forge/assets/index-DQT6EbuS.css">
25
25
  </head>
26
26
  <body class="bg-canvas text-ink antialiased">
27
27
  <div id="root"></div>
@@ -47,6 +47,25 @@ function applyPortToConfig(config, port, portSource) {
47
47
  config.webAppUrl = buildForgeWebAppUrl(config.origin, port);
48
48
  config.portSource = portSource;
49
49
  }
50
+ function getExpectedDataRoot(config) {
51
+ return config.dataRoot.trim().length > 0 ? path.resolve(config.dataRoot) : null;
52
+ }
53
+ function isExpectedDataRoot(expectedDataRoot, actualDataRoot) {
54
+ if (!expectedDataRoot) {
55
+ return true;
56
+ }
57
+ if (!actualDataRoot) {
58
+ return false;
59
+ }
60
+ return path.resolve(actualDataRoot) === expectedDataRoot;
61
+ }
62
+ function formatRuntimeDataRootMismatch(config, expectedDataRoot, actualDataRoot) {
63
+ return [
64
+ `Forge is already responding on ${config.baseUrl}, but it is using storage root ${actualDataRoot ?? "(unknown)"}.`,
65
+ `The OpenClaw plugin is configured to use ${expectedDataRoot}.`,
66
+ "Restart the plugin-managed runtime or stop the conflicting Forge server so the configured dataRoot can take over."
67
+ ].join(" ");
68
+ }
50
69
  async function writePreferredPortState(config, port) {
51
70
  const statePath = getPreferredPortStatePath(config.origin);
52
71
  await mkdir(path.dirname(statePath), { recursive: true });
@@ -272,6 +291,43 @@ async function isForgeHealthy(config, timeoutMs) {
272
291
  clearTimeout(timeout);
273
292
  }
274
293
  }
294
+ async function probeForgeRuntime(config, timeoutMs) {
295
+ const controller = new AbortController();
296
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
297
+ try {
298
+ const response = await fetch(new URL("/api/v1/health", config.baseUrl), {
299
+ method: "GET",
300
+ headers: {
301
+ accept: "application/json",
302
+ "x-forge-runtime-probe": "1"
303
+ },
304
+ signal: controller.signal
305
+ });
306
+ if (!response.ok) {
307
+ return { healthy: false, pid: null, storageRoot: null, basePath: null };
308
+ }
309
+ const payload = (await response.json());
310
+ return {
311
+ healthy: true,
312
+ pid: typeof payload.runtime?.pid === "number" && Number.isFinite(payload.runtime.pid) ? Math.trunc(payload.runtime.pid) : null,
313
+ storageRoot: typeof payload.runtime?.storageRoot === "string" ? path.resolve(payload.runtime.storageRoot) : null,
314
+ basePath: typeof payload.runtime?.basePath === "string" ? payload.runtime.basePath : null
315
+ };
316
+ }
317
+ catch {
318
+ return { healthy: false, pid: null, storageRoot: null, basePath: null };
319
+ }
320
+ finally {
321
+ clearTimeout(timeout);
322
+ }
323
+ }
324
+ async function adoptManagedRuntimeState(config, probe) {
325
+ if (probe.pid === null || !processExists(probe.pid)) {
326
+ return false;
327
+ }
328
+ await writeRuntimeState(config, probe.pid);
329
+ return true;
330
+ }
275
331
  async function spawnManagedRuntime(config, plan) {
276
332
  const isPackagedServer = isPackagedServerPlan(plan);
277
333
  const args = isPackagedServer ? [plan.entryFile] : [plan.entryFile, path.join(plan.packageRoot, "server", "src", "index.ts")];
@@ -353,7 +409,13 @@ export async function ensureForgeRuntimeReady(config) {
353
409
  if (!isLocalOrigin(config.origin)) {
354
410
  return;
355
411
  }
356
- if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
412
+ const expectedDataRoot = getExpectedDataRoot(config);
413
+ const initialProbe = await probeForgeRuntime(config, HEALTHCHECK_TIMEOUT_MS);
414
+ if (initialProbe.healthy && isExpectedDataRoot(expectedDataRoot, initialProbe.storageRoot)) {
415
+ const existingState = await readRuntimeState(config);
416
+ if (!existingState) {
417
+ await adoptManagedRuntimeState(config, initialProbe);
418
+ }
357
419
  return;
358
420
  }
359
421
  const savedState = await readRuntimeState(config);
@@ -361,12 +423,29 @@ export async function ensureForgeRuntimeReady(config) {
361
423
  await clearRuntimeState(config);
362
424
  }
363
425
  else if (savedState && processExists(savedState.pid)) {
426
+ if (initialProbe.healthy && !isExpectedDataRoot(expectedDataRoot, initialProbe.storageRoot)) {
427
+ await stopForgeRuntime(config);
428
+ }
429
+ else {
430
+ try {
431
+ await waitForRuntime(config, EXISTING_RUNTIME_GRACE_MS, null);
432
+ return;
433
+ }
434
+ catch {
435
+ await stopForgeRuntime(config);
436
+ }
437
+ }
438
+ }
439
+ else if (initialProbe.healthy) {
440
+ if (!isExpectedDataRoot(expectedDataRoot, initialProbe.storageRoot)) {
441
+ throw new Error(formatRuntimeDataRootMismatch(config, expectedDataRoot, initialProbe.storageRoot));
442
+ }
364
443
  try {
365
444
  await waitForRuntime(config, EXISTING_RUNTIME_GRACE_MS, null);
366
445
  return;
367
446
  }
368
447
  catch {
369
- await stopForgeRuntime(config);
448
+ // There is no plugin-managed pid to stop here; fall through into normal startup handling.
370
449
  }
371
450
  }
372
451
  const key = runtimeKey(config);
@@ -378,14 +457,16 @@ export async function ensureForgeRuntimeReady(config) {
378
457
  return;
379
458
  }
380
459
  startupPromise = (async () => {
381
- if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
460
+ const probeBeforeStart = await probeForgeRuntime(config, HEALTHCHECK_TIMEOUT_MS);
461
+ if (probeBeforeStart.healthy && isExpectedDataRoot(expectedDataRoot, probeBeforeStart.storageRoot)) {
382
462
  return;
383
463
  }
384
464
  startupRuntimeKey = runtimeKey(config);
385
465
  if (!(await isPortAvailable("127.0.0.1", config.port))) {
386
466
  await relocateLocalRuntimePort(config);
387
467
  startupRuntimeKey = runtimeKey(config);
388
- if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
468
+ const probeAfterRelocation = await probeForgeRuntime(config, HEALTHCHECK_TIMEOUT_MS);
469
+ if (probeAfterRelocation.healthy && isExpectedDataRoot(expectedDataRoot, probeAfterRelocation.storageRoot)) {
389
470
  return;
390
471
  }
391
472
  }
@@ -394,6 +475,10 @@ export async function ensureForgeRuntimeReady(config) {
394
475
  await spawnManagedRuntime(config, plan);
395
476
  }
396
477
  await waitForRuntime(config, STARTUP_TIMEOUT_MS, managedRuntimeChild?.pid ?? null);
478
+ const probeAfterStart = await probeForgeRuntime(config, HEALTHCHECK_TIMEOUT_MS);
479
+ if (!probeAfterStart.healthy || !isExpectedDataRoot(expectedDataRoot, probeAfterStart.storageRoot)) {
480
+ throw new Error(formatRuntimeDataRootMismatch(config, expectedDataRoot, probeAfterStart.storageRoot));
481
+ }
397
482
  })().finally(() => {
398
483
  startupPromise = null;
399
484
  startupRuntimeKey = null;
@@ -411,8 +496,26 @@ export async function startForgeRuntime(config) {
411
496
  baseUrl: config.baseUrl
412
497
  };
413
498
  }
414
- const existingState = await readRuntimeState(config);
415
- if (!existingState && (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS))) {
499
+ const expectedDataRoot = getExpectedDataRoot(config);
500
+ const probe = await probeForgeRuntime(config, HEALTHCHECK_TIMEOUT_MS);
501
+ let existingState = await readRuntimeState(config);
502
+ if (!existingState && probe.healthy && isExpectedDataRoot(expectedDataRoot, probe.storageRoot)) {
503
+ const adopted = await adoptManagedRuntimeState(config, probe);
504
+ if (adopted) {
505
+ existingState = await readRuntimeState(config);
506
+ }
507
+ }
508
+ if (probe.healthy && !isExpectedDataRoot(expectedDataRoot, probe.storageRoot)) {
509
+ return {
510
+ ok: false,
511
+ started: false,
512
+ managed: Boolean(existingState),
513
+ message: formatRuntimeDataRootMismatch(config, expectedDataRoot, probe.storageRoot),
514
+ pid: existingState?.pid ?? null,
515
+ baseUrl: config.baseUrl
516
+ };
517
+ }
518
+ if (!existingState && probe.healthy) {
416
519
  return {
417
520
  ok: true,
418
521
  started: false,
@@ -422,7 +525,7 @@ export async function startForgeRuntime(config) {
422
525
  baseUrl: config.baseUrl
423
526
  };
424
527
  }
425
- if (existingState && processExists(existingState.pid) && (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS))) {
528
+ if (existingState && processExists(existingState.pid) && probe.healthy) {
426
529
  return {
427
530
  ok: true,
428
531
  started: false,
@@ -517,8 +620,16 @@ export async function stopForgeRuntime(config) {
517
620
  };
518
621
  }
519
622
  export async function getForgeRuntimeStatus(config) {
520
- const healthy = await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS);
521
- const state = await readRuntimeState(config);
623
+ const expectedDataRoot = getExpectedDataRoot(config);
624
+ const probe = await probeForgeRuntime(config, HEALTHCHECK_TIMEOUT_MS);
625
+ const healthy = probe.healthy;
626
+ let state = await readRuntimeState(config);
627
+ if (!state && healthy && isExpectedDataRoot(expectedDataRoot, probe.storageRoot)) {
628
+ const adopted = await adoptManagedRuntimeState(config, probe);
629
+ if (adopted) {
630
+ state = await readRuntimeState(config);
631
+ }
632
+ }
522
633
  const pid = state?.pid ?? null;
523
634
  const managed = Boolean(state);
524
635
  const running = healthy || (pid !== null && processExists(pid));
@@ -548,6 +659,17 @@ export async function getForgeRuntimeStatus(config) {
548
659
  };
549
660
  }
550
661
  if (healthy && managed) {
662
+ if (!isExpectedDataRoot(expectedDataRoot, probe.storageRoot)) {
663
+ return {
664
+ ok: false,
665
+ running: true,
666
+ healthy: true,
667
+ managed: true,
668
+ message: formatRuntimeDataRootMismatch(config, expectedDataRoot, probe.storageRoot),
669
+ pid,
670
+ baseUrl: config.baseUrl
671
+ };
672
+ }
551
673
  return {
552
674
  ok: true,
553
675
  running: true,
@@ -559,6 +681,17 @@ export async function getForgeRuntimeStatus(config) {
559
681
  };
560
682
  }
561
683
  if (healthy) {
684
+ if (!isExpectedDataRoot(expectedDataRoot, probe.storageRoot)) {
685
+ return {
686
+ ok: false,
687
+ running: true,
688
+ healthy: true,
689
+ managed: false,
690
+ message: formatRuntimeDataRootMismatch(config, expectedDataRoot, probe.storageRoot),
691
+ pid: null,
692
+ baseUrl: config.baseUrl
693
+ };
694
+ }
562
695
  return {
563
696
  ok: true,
564
697
  running: true,
@@ -12,6 +12,7 @@ export const FORGE_SUPPORTED_PLUGIN_API_ROUTES = [
12
12
  { method: "POST", path: "/api/v1/entities/delete", purpose: "entities" },
13
13
  { method: "POST", path: "/api/v1/entities/restore", purpose: "entities" },
14
14
  { method: "POST", path: "/api/v1/operator/log-work", purpose: "work" },
15
+ { method: "POST", path: "/api/v1/work-adjustments", purpose: "work" },
15
16
  { method: "POST", path: "/api/v1/tasks/:id/runs", purpose: "work" },
16
17
  { method: "GET", path: "/api/v1/task-runs", purpose: "work" },
17
18
  { method: "POST", path: "/api/v1/task-runs/:id/heartbeat", purpose: "work" },
@@ -40,6 +40,12 @@ function normalizeTimeout(value, fallback) {
40
40
  }
41
41
  return Math.min(120_000, Math.max(1000, Math.round(value)));
42
42
  }
43
+ function normalizeDataRoot(value) {
44
+ if (typeof value !== "string" || value.trim().length === 0) {
45
+ return "";
46
+ }
47
+ return path.resolve(value.trim());
48
+ }
43
49
  function isLocalOrigin(origin) {
44
50
  try {
45
51
  return LOCAL_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
@@ -80,7 +86,7 @@ export function resolveForgePluginConfig(pluginConfig) {
80
86
  baseUrl: buildForgeBaseUrl(origin, port),
81
87
  webAppUrl: buildForgeWebAppUrl(origin, port),
82
88
  portSource: hasConfiguredPort ? "configured" : preferredPort !== null ? "preferred" : "default",
83
- dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot.trim() : "",
89
+ dataRoot: normalizeDataRoot(raw.dataRoot),
84
90
  apiToken: typeof raw.apiToken === "string" ? raw.apiToken.trim() : "",
85
91
  actorLabel: normalizeString(raw.actorLabel, "aurel"),
86
92
  timeoutMs: normalizeTimeout(raw.timeoutMs, 15_000)
@@ -140,6 +140,13 @@ export const FORGE_PLUGIN_ROUTE_GROUPS = [
140
140
  requiresToken: true,
141
141
  target: (_match, url) => passthroughSearch("/api/v1/operator/log-work", url)
142
142
  }),
143
+ exact("/forge/v1/work-adjustments", {
144
+ method: "POST",
145
+ upstreamPath: "/api/v1/work-adjustments",
146
+ requestBody: "json",
147
+ requiresToken: true,
148
+ target: (_match, url) => passthroughSearch("/api/v1/work-adjustments", url)
149
+ }),
143
150
  exact("/forge/v1/insights", {
144
151
  method: "POST",
145
152
  upstreamPath: "/api/v1/insights",
@@ -31,6 +31,17 @@ const emptyObjectSchema = Type.Object({});
31
31
  const optionalString = () => Type.Optional(Type.String());
32
32
  const optionalNullableString = () => Type.Optional(Type.Union([Type.String(), Type.Null()]));
33
33
  const optionalDeleteMode = () => Type.Optional(Type.Union([Type.Literal("soft"), Type.Literal("hard")]));
34
+ const noteInputSchema = () => Type.Object({
35
+ contentMarkdown: Type.String({ minLength: 1 }),
36
+ author: optionalNullableString(),
37
+ tags: Type.Optional(Type.Array(Type.String())),
38
+ destroyAt: optionalNullableString(),
39
+ links: Type.Optional(Type.Array(Type.Object({
40
+ entityType: Type.String({ minLength: 1 }),
41
+ entityId: Type.String({ minLength: 1 }),
42
+ anchorKey: optionalNullableString()
43
+ })))
44
+ });
34
45
  async function resolveUiEntrypoint(config) {
35
46
  let webAppUrl = config.webAppUrl;
36
47
  try {
@@ -57,14 +68,27 @@ async function resolveUiEntrypoint(config) {
57
68
  }
58
69
  async function resolveCurrentWork(config) {
59
70
  const payload = await runRead(config, "/api/v1/operator/context");
60
- const context = typeof payload === "object" && payload !== null && "context" in payload && typeof payload.context === "object" && payload.context !== null
71
+ const context = typeof payload === "object" &&
72
+ payload !== null &&
73
+ "context" in payload &&
74
+ typeof payload.context === "object" &&
75
+ payload.context !== null
61
76
  ? payload.context
62
77
  : null;
63
- const recentTaskRuns = Array.isArray(context?.recentTaskRuns) ? context.recentTaskRuns : [];
64
- const activeTaskRuns = recentTaskRuns.filter((run) => typeof run === "object" && run !== null && "status" in run && run.status === "active");
65
- const focusTasks = Array.isArray(context?.focusTasks) ? context.focusTasks : [];
78
+ const recentTaskRuns = Array.isArray(context?.recentTaskRuns)
79
+ ? context.recentTaskRuns
80
+ : [];
81
+ const activeTaskRuns = recentTaskRuns.filter((run) => typeof run === "object" &&
82
+ run !== null &&
83
+ "status" in run &&
84
+ run.status === "active");
85
+ const focusTasks = Array.isArray(context?.focusTasks)
86
+ ? context.focusTasks
87
+ : [];
66
88
  return {
67
- generatedAt: typeof context?.generatedAt === "string" ? context.generatedAt : new Date().toISOString(),
89
+ generatedAt: typeof context?.generatedAt === "string"
90
+ ? context.generatedAt
91
+ : new Date().toISOString(),
68
92
  activeTaskRuns,
69
93
  focusTasks,
70
94
  recommendedNextTask: context?.recommendedNextTask ?? null,
@@ -178,7 +202,7 @@ export function registerForgePluginTools(api, config) {
178
202
  registerWriteTool(api, config, {
179
203
  name: "forge_create_entities",
180
204
  label: "Create Forge Entities",
181
- description: "Create one or more Forge entities through the ordered batch workflow. Pass `operations` as an array. Each operation must include `entityType` and full `data`. Batch several creates together in one call when possible.",
205
+ description: "Create one or more Forge entities through the ordered batch workflow. Pass `operations` as an array. Each operation must include `entityType` and full `data`. This is the preferred create path for planning, Psyche, and calendar records including calendar_event, work_block_template, and task_timebox.",
182
206
  parameters: Type.Object({
183
207
  atomic: Type.Optional(Type.Boolean()),
184
208
  operations: Type.Array(Type.Object({
@@ -193,7 +217,7 @@ export function registerForgePluginTools(api, config) {
193
217
  registerWriteTool(api, config, {
194
218
  name: "forge_update_entities",
195
219
  label: "Update Forge Entities",
196
- description: "Update one or more Forge entities through the ordered batch workflow. Pass `operations` as an array. Each operation must include `entityType`, `id`, and `patch`.",
220
+ description: "Update one or more Forge entities through the ordered batch workflow. Pass `operations` as an array. Each operation must include `entityType`, `id`, and `patch`. This is the preferred update path for calendar_event, work_block_template, and task_timebox too; Forge runs calendar sync side effects downstream.",
197
221
  parameters: Type.Object({
198
222
  atomic: Type.Optional(Type.Boolean()),
199
223
  operations: Type.Array(Type.Object({
@@ -209,7 +233,7 @@ export function registerForgePluginTools(api, config) {
209
233
  registerWriteTool(api, config, {
210
234
  name: "forge_delete_entities",
211
235
  label: "Delete Forge Entities",
212
- description: "Delete Forge entities in one batch request. Pass `operations` as an array with `entityType` and `id`. Delete defaults to soft mode unless hard is requested explicitly.",
236
+ description: "Delete Forge entities in one batch request. Pass `operations` as an array with `entityType` and `id`. Delete defaults to soft mode unless hard is requested explicitly. Calendar-domain deletes still run their downstream removal logic, including remote calendar projection cleanup for calendar_event.",
213
237
  parameters: Type.Object({
214
238
  atomic: Type.Optional(Type.Boolean()),
215
239
  operations: Type.Array(Type.Object({
@@ -238,6 +262,34 @@ export function registerForgePluginTools(api, config) {
238
262
  method: "POST",
239
263
  path: "/api/v1/entities/restore"
240
264
  });
265
+ registerWriteTool(api, config, {
266
+ name: "forge_grant_reward_bonus",
267
+ label: "Forge Grant Reward Bonus",
268
+ description: "Grant an explicit manual XP bonus or penalty with provenance. Use only for auditable operator judgement beyond the normal task-run and habit reward flows.",
269
+ parameters: Type.Object({
270
+ entityType: Type.String({ minLength: 1 }),
271
+ entityId: Type.String({ minLength: 1 }),
272
+ deltaXp: Type.Number(),
273
+ reasonTitle: Type.String({ minLength: 1 }),
274
+ reasonSummary: optionalString(),
275
+ metadata: Type.Optional(Type.Record(Type.String(), Type.Any()))
276
+ }),
277
+ method: "POST",
278
+ path: "/api/v1/rewards/bonus"
279
+ });
280
+ registerWriteTool(api, config, {
281
+ name: "forge_adjust_work_minutes",
282
+ label: "Forge Adjust Work Minutes",
283
+ description: "Add or remove tracked work minutes on an existing task or project without creating a live task run. Forge applies symmetric XP changes when the total crosses reward buckets.",
284
+ parameters: Type.Object({
285
+ entityType: Type.Union([Type.Literal("task"), Type.Literal("project")]),
286
+ entityId: Type.String({ minLength: 1 }),
287
+ deltaMinutes: Type.Integer(),
288
+ note: optionalString()
289
+ }),
290
+ method: "POST",
291
+ path: "/api/v1/work-adjustments"
292
+ });
241
293
  registerWriteTool(api, config, {
242
294
  name: "forge_post_insight",
243
295
  label: "Forge Post Insight",
@@ -275,7 +327,7 @@ export function registerForgePluginTools(api, config) {
275
327
  registerWriteTool(api, config, {
276
328
  name: "forge_log_work",
277
329
  label: "Forge Log Work",
278
- description: "Log retroactive work or mark an existing task as completed through the operator work-log flow. Use this when the user already did the work and wants truthful evidence plus XP.",
330
+ description: "Log retroactive work or mark an existing task as completed through the operator work-log flow. Use this when the user already did the work and wants truthful evidence plus XP. Prefer closeoutNote when the summary should survive as a real linked note.",
279
331
  parameters: Type.Object({
280
332
  taskId: optionalString(),
281
333
  title: optionalString(),
@@ -290,7 +342,8 @@ export function registerForgePluginTools(api, config) {
290
342
  effort: optionalString(),
291
343
  energy: optionalString(),
292
344
  points: Type.Optional(Type.Integer({ minimum: 5, maximum: 500 })),
293
- tagIds: Type.Optional(Type.Array(Type.String()))
345
+ tagIds: Type.Optional(Type.Array(Type.String())),
346
+ closeoutNote: Type.Optional(noteInputSchema())
294
347
  }),
295
348
  method: "POST",
296
349
  path: "/api/v1/operator/log-work"
@@ -304,6 +357,7 @@ export function registerForgePluginTools(api, config) {
304
357
  actor: Type.String({ minLength: 1 }),
305
358
  timerMode: Type.Optional(Type.Union([Type.Literal("planned"), Type.Literal("unlimited")])),
306
359
  plannedDurationSeconds: Type.Optional(Type.Union([Type.Integer({ minimum: 60, maximum: 86400 }), Type.Null()])),
360
+ overrideReason: optionalNullableString(),
307
361
  isCurrent: Type.Optional(Type.Boolean()),
308
362
  leaseTtlSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400 })),
309
363
  note: Type.Optional(Type.String())
@@ -317,6 +371,7 @@ export function registerForgePluginTools(api, config) {
317
371
  actor: typed.actor,
318
372
  timerMode: typed.timerMode,
319
373
  plannedDurationSeconds: typed.plannedDurationSeconds,
374
+ overrideReason: typed.overrideReason,
320
375
  isCurrent: typed.isCurrent,
321
376
  leaseTtlSeconds: typed.leaseTtlSeconds,
322
377
  note: typed.note
@@ -369,11 +424,12 @@ export function registerForgePluginTools(api, config) {
369
424
  api.registerTool({
370
425
  name: "forge_complete_task_run",
371
426
  label: "Forge Complete Task Run",
372
- description: "Finish an active task run as completed work and let Forge award the appropriate completion rewards.",
427
+ description: "Finish an active task run as completed work and let Forge award the appropriate completion rewards. Prefer closeoutNote when the work summary should become a real linked note.",
373
428
  parameters: Type.Object({
374
429
  taskRunId: Type.String({ minLength: 1 }),
375
430
  actor: optionalString(),
376
- note: Type.Optional(Type.String())
431
+ note: Type.Optional(Type.String()),
432
+ closeoutNote: Type.Optional(noteInputSchema())
377
433
  }),
378
434
  async execute(_toolCallId, params) {
379
435
  const typed = params;
@@ -382,7 +438,8 @@ export function registerForgePluginTools(api, config) {
382
438
  path: `/api/v1/task-runs/${typed.taskRunId}/complete`,
383
439
  body: {
384
440
  actor: typed.actor,
385
- note: typed.note
441
+ note: typed.note,
442
+ closeoutNote: typed.closeoutNote
386
443
  }
387
444
  }));
388
445
  }
@@ -390,11 +447,12 @@ export function registerForgePluginTools(api, config) {
390
447
  api.registerTool({
391
448
  name: "forge_release_task_run",
392
449
  label: "Forge Release Task Run",
393
- description: "Stop an active task run without completing it. Use this to truthfully stop current work.",
450
+ description: "Stop an active task run without completing it. Use this to truthfully stop current work. Prefer closeoutNote when blockers or handoff context should become a real linked note.",
394
451
  parameters: Type.Object({
395
452
  taskRunId: Type.String({ minLength: 1 }),
396
453
  actor: optionalString(),
397
- note: Type.Optional(Type.String())
454
+ note: Type.Optional(Type.String()),
455
+ closeoutNote: Type.Optional(noteInputSchema())
398
456
  }),
399
457
  async execute(_toolCallId, params) {
400
458
  const typed = params;
@@ -403,9 +461,133 @@ export function registerForgePluginTools(api, config) {
403
461
  path: `/api/v1/task-runs/${typed.taskRunId}/release`,
404
462
  body: {
405
463
  actor: typed.actor,
406
- note: typed.note
464
+ note: typed.note,
465
+ closeoutNote: typed.closeoutNote
407
466
  }
408
467
  }));
409
468
  }
410
469
  });
470
+ registerReadTool(api, config, {
471
+ name: "forge_get_calendar_overview",
472
+ label: "Forge Calendar Overview",
473
+ description: "Read the calendar domain in one response: provider metadata, connected calendars, Forge-native events, mirrored events, recurring work blocks, and task timeboxes.",
474
+ parameters: Type.Object({
475
+ from: optionalString(),
476
+ to: optionalString()
477
+ }),
478
+ path: (params) => {
479
+ const search = new URLSearchParams();
480
+ if (typeof params.from === "string" && params.from.trim().length > 0) {
481
+ search.set("from", params.from);
482
+ }
483
+ if (typeof params.to === "string" && params.to.trim().length > 0) {
484
+ search.set("to", params.to);
485
+ }
486
+ const suffix = search.size > 0 ? `?${search.toString()}` : "";
487
+ return `/api/v1/calendar/overview${suffix}`;
488
+ }
489
+ });
490
+ registerWriteTool(api, config, {
491
+ name: "forge_connect_calendar_provider",
492
+ label: "Forge Connect Calendar Provider",
493
+ description: "Create a Google, Apple, Exchange Online, or custom CalDAV calendar connection. Use this only for explicit provider-connection requests after discovery choices are known.",
494
+ parameters: Type.Object({
495
+ provider: Type.Union([
496
+ Type.Literal("google"),
497
+ Type.Literal("apple"),
498
+ Type.Literal("caldav"),
499
+ Type.Literal("microsoft")
500
+ ]),
501
+ label: Type.String({ minLength: 1 }),
502
+ username: optionalString(),
503
+ clientId: optionalString(),
504
+ clientSecret: optionalString(),
505
+ refreshToken: optionalString(),
506
+ password: optionalString(),
507
+ serverUrl: optionalString(),
508
+ authSessionId: optionalString(),
509
+ selectedCalendarUrls: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
510
+ forgeCalendarUrl: optionalString(),
511
+ createForgeCalendar: Type.Optional(Type.Boolean())
512
+ }),
513
+ method: "POST",
514
+ path: "/api/v1/calendar/connections"
515
+ });
516
+ api.registerTool({
517
+ name: "forge_sync_calendar_connection",
518
+ label: "Forge Sync Calendar Connection",
519
+ description: "Pull and push changes for one connected calendar provider.",
520
+ parameters: Type.Object({
521
+ connectionId: Type.String({ minLength: 1 })
522
+ }),
523
+ async execute(_toolCallId, params) {
524
+ const typed = params;
525
+ return jsonResult(await runWrite(config, {
526
+ method: "POST",
527
+ path: `/api/v1/calendar/connections/${typed.connectionId}/sync`,
528
+ body: {}
529
+ }));
530
+ }
531
+ });
532
+ registerWriteTool(api, config, {
533
+ name: "forge_create_work_block_template",
534
+ label: "Forge Create Work Block",
535
+ description: "Create a recurring work-block template such as Main Activity, Secondary Activity, Third Activity, Rest, Holiday, or Custom. This is a planning helper; agents can also use forge_create_entities with entityType work_block_template.",
536
+ parameters: Type.Object({
537
+ title: Type.String({ minLength: 1 }),
538
+ kind: Type.Union([
539
+ Type.Literal("main_activity"),
540
+ Type.Literal("secondary_activity"),
541
+ Type.Literal("third_activity"),
542
+ Type.Literal("rest"),
543
+ Type.Literal("holiday"),
544
+ Type.Literal("custom")
545
+ ]),
546
+ color: Type.String({ minLength: 1 }),
547
+ timezone: Type.String({ minLength: 1 }),
548
+ weekDays: Type.Array(Type.Integer({ minimum: 0, maximum: 6 })),
549
+ startMinute: Type.Integer({ minimum: 0, maximum: 1440 }),
550
+ endMinute: Type.Integer({ minimum: 0, maximum: 1440 }),
551
+ startsOn: Type.Optional(Type.Union([Type.String({ minLength: 1 }), Type.Null()])),
552
+ endsOn: Type.Optional(Type.Union([Type.String({ minLength: 1 }), Type.Null()])),
553
+ blockingState: Type.Union([
554
+ Type.Literal("allowed"),
555
+ Type.Literal("blocked")
556
+ ])
557
+ }),
558
+ method: "POST",
559
+ path: "/api/v1/calendar/work-block-templates"
560
+ });
561
+ registerWriteTool(api, config, {
562
+ name: "forge_recommend_task_timeboxes",
563
+ label: "Forge Recommend Task Timeboxes",
564
+ description: "Suggest future task timeboxes that fit the current calendar rules and current schedule.",
565
+ parameters: Type.Object({
566
+ taskId: Type.String({ minLength: 1 }),
567
+ from: optionalString(),
568
+ to: optionalString(),
569
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 24 }))
570
+ }),
571
+ method: "POST",
572
+ path: "/api/v1/calendar/timeboxes/recommend"
573
+ });
574
+ registerWriteTool(api, config, {
575
+ name: "forge_create_task_timebox",
576
+ label: "Forge Create Task Timebox",
577
+ description: "Create a planned task timebox directly in Forge's calendar domain. This is a planning helper; agents can also use forge_create_entities with entityType task_timebox.",
578
+ parameters: Type.Object({
579
+ taskId: Type.String({ minLength: 1 }),
580
+ projectId: optionalNullableString(),
581
+ title: Type.String({ minLength: 1 }),
582
+ startsAt: Type.String({ minLength: 1 }),
583
+ endsAt: Type.String({ minLength: 1 }),
584
+ source: Type.Optional(Type.Union([
585
+ Type.Literal("manual"),
586
+ Type.Literal("suggested"),
587
+ Type.Literal("live_run")
588
+ ]))
589
+ }),
590
+ method: "POST",
591
+ path: "/api/v1/calendar/timeboxes"
592
+ });
411
593
  }