forge-openclaw-plugin 0.2.15 → 0.2.18

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 (41) hide show
  1. package/README.md +6 -3
  2. package/dist/assets/{board-C_m78kvK.js → board-2KevHCI0.js} +2 -2
  3. package/dist/assets/{board-C_m78kvK.js.map → board-2KevHCI0.js.map} +1 -1
  4. package/dist/assets/index-CDYW4WDH.js +36 -0
  5. package/dist/assets/index-CDYW4WDH.js.map +1 -0
  6. package/dist/assets/index-yroQr6YZ.css +1 -0
  7. package/dist/assets/{motion-CpZvZumD.js → motion-q19HPmWs.js} +2 -2
  8. package/dist/assets/{motion-CpZvZumD.js.map → motion-q19HPmWs.js.map} +1 -1
  9. package/dist/assets/{table-DtyXTw03.js → table-BDMHBY4a.js} +2 -2
  10. package/dist/assets/{table-DtyXTw03.js.map → table-BDMHBY4a.js.map} +1 -1
  11. package/dist/assets/{ui-BXbpiKyS.js → ui-CQ_AsFs8.js} +2 -2
  12. package/dist/assets/{ui-BXbpiKyS.js.map → ui-CQ_AsFs8.js.map} +1 -1
  13. package/dist/assets/{vendor-QBH6qVEe.js → vendor-5HifrnRK.js} +90 -75
  14. package/dist/assets/{vendor-QBH6qVEe.js.map → vendor-5HifrnRK.js.map} +1 -1
  15. package/dist/assets/{viz-w-IMeueL.js → viz-CQzkRnTu.js} +2 -2
  16. package/dist/assets/{viz-w-IMeueL.js.map → viz-CQzkRnTu.js.map} +1 -1
  17. package/dist/index.html +8 -8
  18. package/dist/openclaw/local-runtime.js +142 -9
  19. package/dist/openclaw/plugin-entry-shared.js +7 -1
  20. package/dist/openclaw/tools.js +15 -0
  21. package/dist/server/app.js +129 -11
  22. package/dist/server/openapi.js +181 -4
  23. package/dist/server/repositories/habits.js +358 -0
  24. package/dist/server/repositories/rewards.js +62 -0
  25. package/dist/server/services/context.js +16 -6
  26. package/dist/server/services/dashboard.js +6 -3
  27. package/dist/server/services/entity-crud.js +23 -1
  28. package/dist/server/services/gamification.js +66 -18
  29. package/dist/server/services/insights.js +2 -1
  30. package/dist/server/services/reviews.js +2 -1
  31. package/dist/server/types.js +140 -1
  32. package/openclaw.plugin.json +1 -1
  33. package/package.json +1 -1
  34. package/server/migrations/003_habits.sql +30 -0
  35. package/server/migrations/004_habit_links.sql +8 -0
  36. package/server/migrations/005_habit_psyche_links.sql +24 -0
  37. package/skills/forge-openclaw/SKILL.md +16 -2
  38. package/skills/forge-openclaw/cron_jobs.md +395 -0
  39. package/dist/assets/index-BWtLtXwb.js +0 -36
  40. package/dist/assets/index-BWtLtXwb.js.map +0 -1
  41. package/dist/assets/index-Dp5GXY_z.css +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-CDYW4WDH.js"></script>
17
+ <link rel="modulepreload" crossorigin href="/forge/assets/vendor-5HifrnRK.js">
18
+ <link rel="modulepreload" crossorigin href="/forge/assets/motion-q19HPmWs.js">
19
+ <link rel="modulepreload" crossorigin href="/forge/assets/ui-CQ_AsFs8.js">
20
+ <link rel="modulepreload" crossorigin href="/forge/assets/table-BDMHBY4a.js">
21
+ <link rel="modulepreload" crossorigin href="/forge/assets/viz-CQzkRnTu.js">
22
+ <link rel="modulepreload" crossorigin href="/forge/assets/board-2KevHCI0.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-yroQr6YZ.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,
@@ -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)
@@ -238,6 +238,21 @@ export function registerForgePluginTools(api, config) {
238
238
  method: "POST",
239
239
  path: "/api/v1/entities/restore"
240
240
  });
241
+ registerWriteTool(api, config, {
242
+ name: "forge_grant_reward_bonus",
243
+ label: "Forge Grant Reward Bonus",
244
+ 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.",
245
+ parameters: Type.Object({
246
+ entityType: Type.String({ minLength: 1 }),
247
+ entityId: Type.String({ minLength: 1 }),
248
+ deltaXp: Type.Number(),
249
+ reasonTitle: Type.String({ minLength: 1 }),
250
+ reasonSummary: optionalString(),
251
+ metadata: Type.Optional(Type.Record(Type.String(), Type.Any()))
252
+ }),
253
+ method: "POST",
254
+ path: "/api/v1/rewards/bonus"
255
+ });
241
256
  registerWriteTool(api, config, {
242
257
  name: "forge_post_insight",
243
258
  label: "Forge Post Insight",
@@ -7,6 +7,7 @@ import { listActivityEvents, listActivityEventsForTask, removeActivityEvent } fr
7
7
  import { approveApprovalRequest, createAgentAction, createInsight, createInsightFeedback, deleteInsight, getInsightById, listAgentActions, listApprovalRequests, listInsights, rejectApprovalRequest, updateInsight } from "./repositories/collaboration.js";
8
8
  import { listEventLog } from "./repositories/event-log.js";
9
9
  import { createGoal, getGoalById, listGoals, updateGoal } from "./repositories/goals.js";
10
+ import { createHabit, createHabitCheckIn, getHabitById, listHabits, updateHabit } from "./repositories/habits.js";
10
11
  import { listDomains } from "./repositories/domains.js";
11
12
  import { buildNotesSummaryByEntity, createNote, getNoteById, listNotes, updateNote } from "./repositories/notes.js";
12
13
  import { createBehavior, createBehaviorPattern, createBeliefEntry, createEmotionDefinition, createEventType, createModeGuideSession, createModeProfile, createPsycheValue, createTriggerReport, getBehaviorById, getBehaviorPatternById, getBeliefEntryById, getEmotionDefinitionById, getEventTypeById, getModeGuideSessionById, getModeProfileById, getPsycheValueById, getTriggerReportById, listBehaviors, listBehaviorPatterns, listBeliefEntries, listEmotionDefinitions, listEventTypes, listModeGuideSessions, listModeProfiles, listPsycheValues, listSchemaCatalog, listTriggerReports, updateBehavior, updateBehaviorPattern, updateBeliefEntry, updateEmotionDefinition, updateEventType, updateModeGuideSession, updateModeProfile, updatePsycheValue, updateTriggerReport } from "./repositories/psyche.js";
@@ -27,7 +28,7 @@ import { getWeeklyReviewPayload } from "./services/reviews.js";
27
28
  import { createTaskRunWatchdog } from "./services/task-run-watchdog.js";
28
29
  import { suggestTags } from "./services/tagging.js";
29
30
  import { PSYCHE_ENTITY_TYPES, createBehaviorSchema, createBeliefEntrySchema, createBehaviorPatternSchema, createEmotionDefinitionSchema, createEventTypeSchema, createModeGuideSessionSchema, createModeProfileSchema, createPsycheValueSchema, createTriggerReportSchema, updateBehaviorSchema, updateBeliefEntrySchema, updateBehaviorPatternSchema, updateEmotionDefinitionSchema, updateEventTypeSchema, updateModeGuideSessionSchema, updateModeProfileSchema, updatePsycheValueSchema, updateTriggerReportSchema } from "./psyche-types.js";
30
- import { activityListQuerySchema, activitySourceSchema, createAgentActionSchema, createAgentTokenSchema, batchCreateEntitiesSchema, batchDeleteEntitiesSchema, batchRestoreEntitiesSchema, batchSearchEntitiesSchema, batchUpdateEntitiesSchema, createGoalSchema, createInsightFeedbackSchema, createInsightSchema, createNoteSchema, createProjectSchema, createManualRewardGrantSchema, createSessionEventSchema, createTagSchema, notesListQuerySchema, updateTagSchema, createTaskSchema, eventsListQuerySchema, operatorLogWorkSchema, projectBoardPayloadSchema, projectListQuerySchema, entityDeleteQuerySchema, removeActivityEventSchema, resolveApprovalRequestSchema, rewardsLedgerQuerySchema, taskContextPayloadSchema, taskRunClaimSchema, taskRunFocusSchema, taskRunFinishSchema, taskRunHeartbeatSchema, taskRunListQuerySchema, taskListQuerySchema, tagSuggestionRequestSchema, uncompleteTaskSchema, updateSettingsSchema, updateGoalSchema, updateInsightSchema, updateNoteSchema, updateProjectSchema, updateRewardRuleSchema, updateTaskSchema } from "./types.js";
31
+ import { activityListQuerySchema, activitySourceSchema, createAgentActionSchema, createAgentTokenSchema, batchCreateEntitiesSchema, batchDeleteEntitiesSchema, batchRestoreEntitiesSchema, batchSearchEntitiesSchema, batchUpdateEntitiesSchema, createGoalSchema, createInsightFeedbackSchema, createInsightSchema, createNoteSchema, createProjectSchema, createManualRewardGrantSchema, createHabitCheckInSchema, createHabitSchema, createSessionEventSchema, createTagSchema, notesListQuerySchema, updateTagSchema, createTaskSchema, eventsListQuerySchema, operatorLogWorkSchema, projectBoardPayloadSchema, projectListQuerySchema, entityDeleteQuerySchema, removeActivityEventSchema, resolveApprovalRequestSchema, rewardsLedgerQuerySchema, habitListQuerySchema, taskContextPayloadSchema, taskRunClaimSchema, taskRunFocusSchema, taskRunFinishSchema, taskRunHeartbeatSchema, taskRunListQuerySchema, taskListQuerySchema, tagSuggestionRequestSchema, uncompleteTaskSchema, updateSettingsSchema, updateGoalSchema, updateHabitSchema, updateInsightSchema, updateNoteSchema, updateProjectSchema, updateRewardRuleSchema, updateTaskSchema } from "./types.js";
31
32
  import { buildOpenApiDocument } from "./openapi.js";
32
33
  import { registerWebRoutes } from "./web.js";
33
34
  import { createManagerRuntime } from "./managers/runtime.js";
@@ -186,6 +187,39 @@ const AGENT_ONBOARDING_ENTITY_CATALOG = [
186
187
  { name: "notes", type: "Array<{ contentMarkdown, author?, links? }>", required: false, description: "Optional nested notes that will auto-link to the new task.", defaultValue: [] }
187
188
  ]
188
189
  },
190
+ {
191
+ entityType: "habit",
192
+ purpose: "A recurring commitment or recurring slip with explicit cadence, graph links, and XP consequences.",
193
+ minimumCreateFields: ["title"],
194
+ relationshipRules: [
195
+ "Habits can link directly to goals, projects, tasks, values, patterns, behaviors, beliefs, modes, and trigger reports.",
196
+ "Habits are recurring records, not task variants, and they participate in search, notes, delete/restore, and XP.",
197
+ "linkedBehaviorId remains a compatibility alias; linkedBehaviorIds is the canonical array form."
198
+ ],
199
+ searchHints: ["Search by title before creating a duplicate habit.", "Use linkedTo when the habit should already be attached to a goal, project, task, or Psyche entity."],
200
+ examples: ['{"title":"Morning training","frequency":"daily","polarity":"positive","linkedGoalIds":["goal_train_body"],"linkedValueIds":["value_steadiness"],"linkedBehaviorIds":["behavior_regulating_walk"]}'],
201
+ fieldGuide: [
202
+ { name: "title", type: "string", required: true, description: "Concrete recurring behavior label." },
203
+ { name: "description", type: "string", required: false, description: "What counts as success or failure for this habit.", defaultValue: "" },
204
+ { name: "status", type: "active|paused|archived", required: false, description: "Lifecycle state.", enumValues: ["active", "paused", "archived"], defaultValue: "active" },
205
+ { name: "polarity", type: "positive|negative", required: false, description: "Whether doing the behavior is aligned or misaligned.", enumValues: ["positive", "negative"], defaultValue: "positive" },
206
+ { name: "frequency", type: "daily|weekly", required: false, description: "Recurrence cadence.", enumValues: ["daily", "weekly"], defaultValue: "daily" },
207
+ { name: "targetCount", type: "integer", required: false, description: "How many repetitions define the cadence window.", defaultValue: 1 },
208
+ { name: "weekDays", type: "integer[]", required: false, description: "Weekday numbers for weekly habits where Monday is 1 and Sunday is 0.", defaultValue: [] },
209
+ { name: "linkedGoalIds", type: "string[]", required: false, description: "Linked goal ids.", defaultValue: [] },
210
+ { name: "linkedProjectIds", type: "string[]", required: false, description: "Linked project ids.", defaultValue: [] },
211
+ { name: "linkedTaskIds", type: "string[]", required: false, description: "Linked task ids.", defaultValue: [] },
212
+ { name: "linkedValueIds", type: "string[]", required: false, description: "Linked value ids.", defaultValue: [] },
213
+ { name: "linkedPatternIds", type: "string[]", required: false, description: "Linked pattern ids.", defaultValue: [] },
214
+ { name: "linkedBehaviorIds", type: "string[]", required: false, description: "Canonical linked behavior ids.", defaultValue: [] },
215
+ { name: "linkedBehaviorId", type: "string|null", required: false, description: "Compatibility alias for the first linked behavior id.", defaultValue: null, nullable: true },
216
+ { name: "linkedBeliefIds", type: "string[]", required: false, description: "Linked belief ids.", defaultValue: [] },
217
+ { name: "linkedModeIds", type: "string[]", required: false, description: "Linked mode ids.", defaultValue: [] },
218
+ { name: "linkedReportIds", type: "string[]", required: false, description: "Linked trigger report ids.", defaultValue: [] },
219
+ { name: "rewardXp", type: "integer", required: false, description: "XP granted on aligned check-ins.", defaultValue: 12 },
220
+ { name: "penaltyXp", type: "integer", required: false, description: "XP removed on misaligned check-ins.", defaultValue: 8 }
221
+ ]
222
+ },
189
223
  {
190
224
  entityType: "note",
191
225
  purpose: "A Markdown note that can link to one or many Forge entities.",
@@ -594,6 +628,15 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
594
628
  notes: ["Restore only works for soft-deleted entities."],
595
629
  example: '{"operations":[{"entityType":"goal","id":"goal_123","clientRef":"goal-restore-1"}]}'
596
630
  },
631
+ {
632
+ toolName: "forge_grant_reward_bonus",
633
+ summary: "Grant an explicit manual XP bonus or penalty with clear provenance.",
634
+ whenToUse: "Use when the user or operator explicitly wants an auditable reward adjustment beyond the automatic task and habit reward paths.",
635
+ inputShape: "{ entityType: RewardableEntityType, entityId: string, deltaXp: integer, reasonTitle: string, reasonSummary?: string, metadata?: object }",
636
+ requiredFields: ["entityType", "entityId", "deltaXp", "reasonTitle"],
637
+ notes: ["Requires rewards.manage and write scopes.", "Use this for explicit operator judgement, not as a substitute for normal task_run or habit check-in rewards."],
638
+ example: '{"entityType":"habit","entityId":"habit_morning_training","deltaXp":18,"reasonTitle":"Operator bonus","reasonSummary":"Stayed with the habit through unusual travel friction.","metadata":{"manual":true,"source":"agent"}}'
639
+ },
597
640
  {
598
641
  toolName: "forge_post_insight",
599
642
  summary: "Store an agent-authored insight.",
@@ -736,6 +779,7 @@ function buildAgentOnboardingPayload(request) {
736
779
  "Goals are the top-level strategic layer.",
737
780
  "Projects belong to one goal through goalId.",
738
781
  "Tasks can belong to a goal, a project, both, or neither.",
782
+ "Habits are recurring records that can connect directly to goals, projects, tasks, and durable Psyche entities.",
739
783
  "Task runs represent live work sessions on tasks and are separate from task status.",
740
784
  "Notes can link to one or many entities and are the canonical place for Markdown progress context or close-out evidence.",
741
785
  "Psyche values can link to goals, projects, and tasks.",
@@ -771,6 +815,7 @@ function buildAgentOnboardingPayload(request) {
771
815
  "forge_delete_entities",
772
816
  "forge_restore_entities"
773
817
  ],
818
+ rewardWorkflow: ["forge_grant_reward_bonus"],
774
819
  workWorkflow: [
775
820
  "forge_log_work",
776
821
  "forge_start_task_run",
@@ -955,7 +1000,17 @@ function buildHealthPayload(taskRunWatchdog, extras = {}) {
955
1000
  ...extras
956
1001
  };
957
1002
  }
1003
+ function shouldIncludeRuntimeProbe(headers) {
1004
+ const probeHeader = headers["x-forge-runtime-probe"];
1005
+ if (Array.isArray(probeHeader)) {
1006
+ return probeHeader.some((value) => typeof value === "string" && value.trim() === "1");
1007
+ }
1008
+ return typeof probeHeader === "string" && probeHeader.trim() === "1";
1009
+ }
958
1010
  function buildV1Context() {
1011
+ const goals = listGoals();
1012
+ const tasks = listTasks();
1013
+ const habits = listHabits();
959
1014
  return {
960
1015
  meta: {
961
1016
  apiVersion: "v1",
@@ -964,15 +1019,16 @@ function buildV1Context() {
964
1019
  backend: "forge-node-runtime",
965
1020
  mode: "transitional-node"
966
1021
  },
967
- metrics: buildGamificationProfile(listGoals(), listTasks()),
1022
+ metrics: buildGamificationProfile(goals, tasks, habits),
968
1023
  dashboard: getDashboard(),
969
1024
  overview: getOverviewContext(),
970
1025
  today: getTodayContext(),
971
1026
  risk: getRiskContext(),
972
- goals: listGoals(),
1027
+ goals,
973
1028
  projects: listProjectSummaries(),
974
1029
  tags: listTags(),
975
- tasks: listTasks(),
1030
+ tasks,
1031
+ habits,
976
1032
  activeTaskRuns: listTaskRuns({ active: true, limit: 25 }),
977
1033
  activity: listActivityEvents({ limit: 25 })
978
1034
  };
@@ -980,8 +1036,9 @@ function buildV1Context() {
980
1036
  function buildXpMetricsPayload() {
981
1037
  const goals = listGoals();
982
1038
  const tasks = listTasks();
1039
+ const habits = listHabits();
983
1040
  const rules = listRewardRules();
984
- const gamificationOverview = buildGamificationOverview(goals, tasks);
1041
+ const gamificationOverview = buildGamificationOverview(goals, tasks, habits);
985
1042
  const dailyAmbientCap = rules
986
1043
  .filter((rule) => rule.family === "ambient")
987
1044
  .reduce((max, rule) => Math.max(max, Number(rule.config.dailyCap ?? 0)), 0) || 12;
@@ -989,7 +1046,7 @@ function buildXpMetricsPayload() {
989
1046
  profile: gamificationOverview.profile,
990
1047
  achievements: gamificationOverview.achievements,
991
1048
  milestoneRewards: gamificationOverview.milestoneRewards,
992
- momentumPulse: buildXpMomentumPulse(goals, tasks),
1049
+ momentumPulse: buildXpMomentumPulse(goals, tasks, habits),
993
1050
  recentLedger: listRewardLedger({ limit: 25 }),
994
1051
  rules,
995
1052
  dailyAmbientXp: getDailyAmbientXp(new Date().toISOString().slice(0, 10)),
@@ -998,6 +1055,7 @@ function buildXpMetricsPayload() {
998
1055
  }
999
1056
  function buildOperatorContext() {
1000
1057
  const tasks = listTasks();
1058
+ const dueHabits = listHabits({ dueToday: true }).slice(0, 12);
1001
1059
  const activeProjects = listProjectSummaries({ status: "active" }).filter((project) => project.activeTaskCount > 0 || project.completedTaskCount > 0);
1002
1060
  const focusTasks = tasks.filter((task) => task.status === "focus" || task.status === "in_progress");
1003
1061
  const recommendedNextTask = focusTasks[0] ??
@@ -1008,6 +1066,7 @@ function buildOperatorContext() {
1008
1066
  generatedAt: new Date().toISOString(),
1009
1067
  activeProjects: activeProjects.slice(0, 8),
1010
1068
  focusTasks: focusTasks.slice(0, 12),
1069
+ dueHabits,
1011
1070
  currentBoard: {
1012
1071
  backlog: tasks.filter((task) => task.status === "backlog").slice(0, 20),
1013
1072
  focus: tasks.filter((task) => task.status === "focus").slice(0, 20),
@@ -1231,9 +1290,18 @@ export async function buildServer(options = {}) {
1231
1290
  return context;
1232
1291
  };
1233
1292
  app.get("/api/health", async () => buildHealthPayload(taskRunWatchdog));
1234
- app.get("/api/v1/health", async () => buildHealthPayload(taskRunWatchdog, {
1293
+ app.get("/api/v1/health", async (request) => buildHealthPayload(taskRunWatchdog, {
1235
1294
  apiVersion: "v1",
1236
- backend: "forge-node-runtime"
1295
+ backend: "forge-node-runtime",
1296
+ ...(shouldIncludeRuntimeProbe(request.headers)
1297
+ ? {
1298
+ runtime: {
1299
+ pid: process.pid,
1300
+ storageRoot: runtimeConfig.dataRoot ?? process.cwd(),
1301
+ basePath: runtimeConfig.basePath
1302
+ }
1303
+ }
1304
+ : {})
1237
1305
  }));
1238
1306
  app.get("/api/v1/auth/operator-session", async (request, reply) => ({
1239
1307
  session: managers.session.ensureLocalOperatorSession(request.headers, reply)
@@ -1721,6 +1789,19 @@ export async function buildServer(options = {}) {
1721
1789
  const query = taskListQuerySchema.parse(request.query ?? {});
1722
1790
  return { tasks: listTasks(query) };
1723
1791
  });
1792
+ app.get("/api/v1/habits", async (request) => {
1793
+ const query = habitListQuerySchema.parse(request.query ?? {});
1794
+ return { habits: listHabits(query) };
1795
+ });
1796
+ app.get("/api/v1/habits/:id", async (request, reply) => {
1797
+ const { id } = request.params;
1798
+ const habit = getHabitById(id);
1799
+ if (!habit) {
1800
+ reply.code(404);
1801
+ return { error: "Habit not found" };
1802
+ }
1803
+ return { habit };
1804
+ });
1724
1805
  app.get("/api/v1/projects/:id", async (request, reply) => {
1725
1806
  const { id } = request.params;
1726
1807
  const project = listProjectSummaries().find((entry) => entry.id === id);
@@ -1764,7 +1845,7 @@ export async function buildServer(options = {}) {
1764
1845
  return { event };
1765
1846
  });
1766
1847
  app.get("/api/v1/metrics", async () => ({
1767
- metrics: buildGamificationOverview(listGoals(), listTasks())
1848
+ metrics: buildGamificationOverview(listGoals(), listTasks(), listHabits())
1768
1849
  }));
1769
1850
  app.get("/api/v1/metrics/xp", async () => ({
1770
1851
  metrics: buildXpMetricsPayload()
@@ -1944,6 +2025,12 @@ export async function buildServer(options = {}) {
1944
2025
  reply.code(201);
1945
2026
  return { project };
1946
2027
  });
2028
+ app.post("/api/v1/habits", async (request, reply) => {
2029
+ const auth = requireScopedAccess(request.headers, ["write"], { route: "/api/v1/habits" });
2030
+ const habit = createHabit(createHabitSchema.parse(request.body ?? {}), toActivityContext(auth));
2031
+ reply.code(201);
2032
+ return { habit };
2033
+ });
1947
2034
  app.patch("/api/v1/projects/:id", async (request, reply) => {
1948
2035
  const auth = requireScopedAccess(request.headers, ["write"], { route: "/api/v1/projects/:id" });
1949
2036
  const { id } = request.params;
@@ -1964,6 +2051,36 @@ export async function buildServer(options = {}) {
1964
2051
  }
1965
2052
  return { project };
1966
2053
  });
2054
+ app.patch("/api/v1/habits/:id", async (request, reply) => {
2055
+ const auth = requireScopedAccess(request.headers, ["write"], { route: "/api/v1/habits/:id" });
2056
+ const { id } = request.params;
2057
+ const habit = updateHabit(id, updateHabitSchema.parse(request.body ?? {}), toActivityContext(auth));
2058
+ if (!habit) {
2059
+ reply.code(404);
2060
+ return { error: "Habit not found" };
2061
+ }
2062
+ return { habit };
2063
+ });
2064
+ app.delete("/api/v1/habits/:id", async (request, reply) => {
2065
+ const auth = requireScopedAccess(request.headers, ["write"], { route: "/api/v1/habits/:id" });
2066
+ const { id } = request.params;
2067
+ const habit = deleteEntity("habit", id, entityDeleteQuerySchema.parse(request.query ?? {}), toActivityContext(auth));
2068
+ if (!habit) {
2069
+ reply.code(404);
2070
+ return { error: "Habit not found" };
2071
+ }
2072
+ return { habit };
2073
+ });
2074
+ app.post("/api/v1/habits/:id/check-ins", async (request, reply) => {
2075
+ const auth = requireScopedAccess(request.headers, ["write"], { route: "/api/v1/habits/:id/check-ins" });
2076
+ const { id } = request.params;
2077
+ const habit = createHabitCheckIn(id, createHabitCheckInSchema.parse(request.body ?? {}), toActivityContext(auth));
2078
+ if (!habit) {
2079
+ reply.code(404);
2080
+ return { error: "Habit not found" };
2081
+ }
2082
+ return { habit, metrics: buildXpMetricsPayload() };
2083
+ });
1967
2084
  app.patch("/api/v1/settings", async (request) => {
1968
2085
  const auth = requireScopedAccess(request.headers, ["write"], { route: "/api/v1/settings" });
1969
2086
  return {
@@ -2072,7 +2189,7 @@ export async function buildServer(options = {}) {
2072
2189
  app.get("/api/metrics", async (_request, reply) => {
2073
2190
  markCompatibilityRoute(reply);
2074
2191
  return {
2075
- metrics: buildGamificationProfile(listGoals(), listTasks())
2192
+ metrics: buildGamificationProfile(listGoals(), listTasks(), listHabits())
2076
2193
  };
2077
2194
  });
2078
2195
  app.get("/api/task-runs", async (request, reply) => {
@@ -2102,7 +2219,7 @@ export async function buildServer(options = {}) {
2102
2219
  markCompatibilityRoute(reply);
2103
2220
  const query = taskListQuerySchema.parse(request.query ?? {});
2104
2221
  return {
2105
- metrics: buildGamificationProfile(listGoals(), listTasks()),
2222
+ metrics: buildGamificationProfile(listGoals(), listTasks(), listHabits()),
2106
2223
  dashboard: getDashboard(),
2107
2224
  overview: getOverviewContext(),
2108
2225
  today: getTodayContext(),
@@ -2111,6 +2228,7 @@ export async function buildServer(options = {}) {
2111
2228
  projects: listProjectSummaries(),
2112
2229
  tags: listTags(),
2113
2230
  tasks: listTasks(query),
2231
+ habits: listHabits(),
2114
2232
  activeTaskRuns: listTaskRuns({ active: true, limit: 25 }),
2115
2233
  activity: listActivityEvents({ limit: 25 })
2116
2234
  };