jfl 0.1.1 → 0.2.1

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 (112) hide show
  1. package/README.md +77 -7
  2. package/clawdbot-plugin/clawdbot.plugin.json +20 -0
  3. package/clawdbot-plugin/index.js +555 -0
  4. package/clawdbot-plugin/index.ts +582 -0
  5. package/clawdbot-skill/SKILL.md +33 -336
  6. package/clawdbot-skill/index.ts +516 -319
  7. package/clawdbot-skill/skill.json +4 -13
  8. package/dist/commands/clawdbot.d.ts +11 -0
  9. package/dist/commands/clawdbot.d.ts.map +1 -0
  10. package/dist/commands/clawdbot.js +215 -0
  11. package/dist/commands/clawdbot.js.map +1 -0
  12. package/dist/commands/gtm-process-update.d.ts +10 -0
  13. package/dist/commands/gtm-process-update.d.ts.map +1 -0
  14. package/dist/commands/gtm-process-update.js +101 -0
  15. package/dist/commands/gtm-process-update.js.map +1 -0
  16. package/dist/commands/onboard.d.ts.map +1 -1
  17. package/dist/commands/onboard.js +203 -15
  18. package/dist/commands/onboard.js.map +1 -1
  19. package/dist/commands/openclaw.d.ts +56 -0
  20. package/dist/commands/openclaw.d.ts.map +1 -0
  21. package/dist/commands/openclaw.js +700 -0
  22. package/dist/commands/openclaw.js.map +1 -0
  23. package/dist/commands/service-validate.d.ts +12 -0
  24. package/dist/commands/service-validate.d.ts.map +1 -0
  25. package/dist/commands/service-validate.js +611 -0
  26. package/dist/commands/service-validate.js.map +1 -0
  27. package/dist/commands/services-create.d.ts +15 -0
  28. package/dist/commands/services-create.d.ts.map +1 -0
  29. package/dist/commands/services-create.js +1452 -0
  30. package/dist/commands/services-create.js.map +1 -0
  31. package/dist/commands/services-sync-agents.d.ts +23 -0
  32. package/dist/commands/services-sync-agents.d.ts.map +1 -0
  33. package/dist/commands/services-sync-agents.js +207 -0
  34. package/dist/commands/services-sync-agents.js.map +1 -0
  35. package/dist/commands/services.d.ts +7 -1
  36. package/dist/commands/services.d.ts.map +1 -1
  37. package/dist/commands/services.js +347 -22
  38. package/dist/commands/services.js.map +1 -1
  39. package/dist/commands/update.js +0 -0
  40. package/dist/commands/validate-settings.d.ts +37 -0
  41. package/dist/commands/validate-settings.d.ts.map +1 -0
  42. package/dist/commands/validate-settings.js +197 -0
  43. package/dist/commands/validate-settings.js.map +1 -0
  44. package/dist/index.js +155 -60
  45. package/dist/index.js.map +1 -1
  46. package/dist/lib/agent-generator.d.ts.map +1 -1
  47. package/dist/lib/agent-generator.js +94 -1
  48. package/dist/lib/agent-generator.js.map +1 -1
  49. package/dist/lib/openclaw-registry.d.ts +48 -0
  50. package/dist/lib/openclaw-registry.d.ts.map +1 -0
  51. package/dist/lib/openclaw-registry.js +181 -0
  52. package/dist/lib/openclaw-registry.js.map +1 -0
  53. package/dist/lib/openclaw-sdk.d.ts +107 -0
  54. package/dist/lib/openclaw-sdk.d.ts.map +1 -0
  55. package/dist/lib/openclaw-sdk.js +208 -0
  56. package/dist/lib/openclaw-sdk.js.map +1 -0
  57. package/dist/lib/peer-agent-generator.d.ts +44 -0
  58. package/dist/lib/peer-agent-generator.d.ts.map +1 -0
  59. package/dist/lib/peer-agent-generator.js +286 -0
  60. package/dist/lib/peer-agent-generator.js.map +1 -0
  61. package/dist/lib/service-detector.d.ts +1 -1
  62. package/dist/lib/service-detector.d.ts.map +1 -1
  63. package/dist/lib/service-detector.js +118 -5
  64. package/dist/lib/service-detector.js.map +1 -1
  65. package/dist/lib/service-gtm.d.ts +157 -0
  66. package/dist/lib/service-gtm.d.ts.map +1 -0
  67. package/dist/lib/service-gtm.js +786 -0
  68. package/dist/lib/service-gtm.js.map +1 -0
  69. package/dist/lib/service-mcp-base.d.ts +10 -1
  70. package/dist/lib/service-mcp-base.d.ts.map +1 -1
  71. package/dist/lib/service-mcp-base.js +20 -1
  72. package/dist/lib/service-mcp-base.js.map +1 -1
  73. package/dist/mcp/service-peer-mcp.d.ts +36 -0
  74. package/dist/mcp/service-peer-mcp.d.ts.map +1 -0
  75. package/dist/mcp/service-peer-mcp.js +220 -0
  76. package/dist/mcp/service-peer-mcp.js.map +1 -0
  77. package/dist/mcp/service-registry-mcp.js +0 -0
  78. package/dist/utils/settings-validator.d.ts +4 -1
  79. package/dist/utils/settings-validator.d.ts.map +1 -1
  80. package/dist/utils/settings-validator.js +25 -1
  81. package/dist/utils/settings-validator.js.map +1 -1
  82. package/package.json +2 -1
  83. package/template/.claude/service-settings.json +32 -0
  84. package/template/.claude/settings.json +10 -0
  85. package/template/.claude/skills/end/SKILL.md +1780 -0
  86. package/template/.jfl/config.json +2 -1
  87. package/template/.mcp.json +1 -7
  88. package/template/CLAUDE.md +1042 -248
  89. package/template/CLAUDE.md.bak +1187 -0
  90. package/template/scripts/commit-gtm.sh +56 -0
  91. package/template/scripts/commit-product.sh +68 -0
  92. package/template/scripts/migrate-to-branch-sessions.sh +201 -0
  93. package/template/scripts/session/auto-commit.sh +4 -3
  94. package/template/scripts/session/jfl-doctor.sh +222 -83
  95. package/template/scripts/session/session-cleanup.sh +109 -21
  96. package/template/scripts/session/session-end.sh +26 -13
  97. package/template/scripts/session/session-init.sh +280 -98
  98. package/template/scripts/session/test-critical-infrastructure.sh +293 -0
  99. package/template/scripts/session/test-experience-level.sh +336 -0
  100. package/template/scripts/session/test-session-cleanup.sh +268 -0
  101. package/template/scripts/session/test-session-sync.sh +320 -0
  102. package/template/scripts/where-am-i.sh +78 -0
  103. package/template/templates/service-agent/.claude/settings.json +32 -0
  104. package/template/templates/service-agent/CLAUDE.md +334 -0
  105. package/template/templates/service-agent/knowledge/ARCHITECTURE.md +115 -0
  106. package/template/templates/service-agent/knowledge/DEPLOYMENT.md +199 -0
  107. package/template/templates/service-agent/knowledge/RUNBOOK.md +412 -0
  108. package/template/templates/service-agent/knowledge/SERVICE_SPEC.md +77 -0
  109. package/dist/commands/session-mgmt.d.ts +0 -33
  110. package/dist/commands/session-mgmt.d.ts.map +0 -1
  111. package/dist/commands/session-mgmt.js +0 -404
  112. package/dist/commands/session-mgmt.js.map +0 -1
@@ -1,14 +1,20 @@
1
1
  /**
2
2
  * JFL GTM Clawdbot Skill
3
3
  *
4
- * Provides full JFL CLI access from Telegram/Slack with proper session isolation.
5
- * Uses `jfl session create` and `jfl session exec` for worktree isolation, auto-commit, and journaling.
6
- * Session state persisted to ~/.clawd/memory/jfl-sessions.json
4
+ * Dormant until user engages via /jfl. Then activates sessions, context,
5
+ * journaling, and coordination via OpenClaw protocol.
6
+ *
7
+ * On install: explains what JFL does and how to use it.
8
+ * On /jfl: lets user pick a GTM workspace, then activates.
9
+ * While active: auto-injects context, captures decisions, auto-commits.
10
+ *
11
+ * @purpose Clawdbot skill using OpenClaw for full JFL integration
12
+ * @spec specs/OPENCLAW_SPEC.md
7
13
  */
8
14
 
9
15
  import { exec } from "child_process"
10
16
  import { promisify } from "util"
11
- import { existsSync, readFileSync } from "fs"
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs"
12
18
  import { join } from "path"
13
19
  import { homedir } from "os"
14
20
 
@@ -25,462 +31,653 @@ interface GTM {
25
31
  path: string
26
32
  }
27
33
 
28
- /**
29
- * Boot sequence - runs when skill first activates
30
- */
31
- export async function onBoot(ctx: Context) {
32
- // Check if JFL CLI is installed
33
- const hasJFL = await checkJFLInstalled()
34
+ interface SessionState {
35
+ gtmPath: string
36
+ gtmName: string
37
+ agentId: string
38
+ sessionBranch: string | null
39
+ activated: boolean
40
+ }
34
41
 
35
- if (!hasJFL) {
36
- return {
37
- text: "⚠️ JFL CLI not found\n\n" +
38
- "Install: npm install -g jfl\n\n" +
39
- "Then run /jfl again",
40
- buttons: []
41
- }
42
- }
42
+ // ============================================================================
43
+ // State persistence
44
+ // ============================================================================
43
45
 
44
- // Find available GTMs
45
- const gtms = await findGTMs()
46
+ const STATE_DIR = join(homedir(), ".clawd", "memory")
47
+ const STATE_FILE = join(STATE_DIR, "jfl-openclaw.json")
46
48
 
47
- if (gtms.length === 0) {
48
- return {
49
- text: "🚀 JFL - Just Fucking Launch\n\n" +
50
- "No GTMs found.\n\n" +
51
- "Create one: jfl init\n" +
52
- "Then run /jfl again"
49
+ function loadState(): Map<string, SessionState> {
50
+ try {
51
+ if (existsSync(STATE_FILE)) {
52
+ return new Map(Object.entries(JSON.parse(readFileSync(STATE_FILE, "utf-8"))))
53
53
  }
54
- }
55
-
56
- // Show GTM picker (like screenshot)
57
- const buttons = gtms.map(g => ({
58
- text: `📂 ${g.name}`,
59
- callbackData: `select:${g.path}`
60
- }))
61
-
62
- return {
63
- text: "🚀 JFL - Just Fucking Launch\n\n" +
64
- "Your team's context layer. Any AI. Any task.\n\n" +
65
- "Open a project:",
66
- buttons
67
- }
54
+ } catch { /* ignore */ }
55
+ return new Map()
68
56
  }
69
57
 
70
- /**
71
- * Handle button clicks
72
- */
73
- export async function onCallback(data: string, ctx: Context) {
74
- const [action, value] = data.split(":")
75
-
76
- if (action === "select") {
77
- return await handleSelectGTM(value, ctx)
78
- }
79
-
80
- if (action === "cmd") {
81
- return await handleCommand(value, ctx)
82
- }
83
-
84
- return { text: "Unknown action" }
58
+ function saveState(state: Map<string, SessionState>) {
59
+ mkdirSync(STATE_DIR, { recursive: true })
60
+ writeFileSync(STATE_FILE, JSON.stringify(Object.fromEntries(state), null, 2))
85
61
  }
86
62
 
87
- /**
88
- * Handle slash commands
89
- */
90
- export async function onCommand(cmd: string, args: string[], ctx: Context) {
91
- const session = getSession(ctx.threadId)
63
+ const state = loadState()
92
64
 
93
- if (!session && cmd !== "gtm") {
94
- return {
95
- text: "⚠️ No GTM selected.\n\nRun /jfl to select a project."
96
- }
97
- }
98
-
99
- switch (cmd) {
100
- case "gtm":
101
- return await onBoot(ctx)
65
+ function getState(threadId: string): SessionState | undefined {
66
+ return state.get(threadId)
67
+ }
102
68
 
103
- case "hud":
104
- return await runJFLCommand(session!, "hud")
69
+ function setState(threadId: string, s: SessionState) {
70
+ state.set(threadId, s)
71
+ saveState(state)
72
+ }
105
73
 
106
- case "crm":
107
- if (args.length === 0) {
108
- return showCRMMenu()
109
- }
110
- return await runCRMCommand(session!, args)
74
+ // ============================================================================
75
+ // OpenClaw helpers
76
+ // ============================================================================
77
+
78
+ async function openclaw(cmd: string, cwd?: string): Promise<string> {
79
+ const { stdout } = await execAsync(`jfl openclaw ${cmd}`, {
80
+ timeout: 30000,
81
+ env: { ...process.env },
82
+ cwd,
83
+ })
84
+ return stdout.trim()
85
+ }
111
86
 
112
- case "brand":
113
- return showBrandMenu()
87
+ async function openclawJSON(cmd: string, cwd?: string): Promise<any> {
88
+ const raw = await openclaw(`${cmd} --json`, cwd)
89
+ return JSON.parse(raw)
90
+ }
114
91
 
115
- case "content":
116
- if (args.length === 0) {
117
- return showContentMenu()
118
- }
119
- return await runJFLCommand(session!, `content ${args.join(" ")}`)
92
+ async function ensureContextHub(gtmPath: string): Promise<boolean> {
93
+ try {
94
+ // Check if hub is healthy
95
+ const status = await openclawJSON("status", gtmPath)
96
+ if (status.context_hub?.healthy) return true
97
+ } catch { /* hub not running */ }
120
98
 
121
- default:
122
- return { text: `Unknown command: /${cmd}` }
99
+ // Start hub from within GTM directory
100
+ try {
101
+ await execAsync("jfl context-hub start", {
102
+ cwd: gtmPath,
103
+ timeout: 15000,
104
+ env: { ...process.env },
105
+ })
106
+ return true
107
+ } catch {
108
+ return false
123
109
  }
124
110
  }
125
111
 
126
- /**
127
- * Check if JFL CLI is installed
128
- */
129
- async function checkJFLInstalled(): Promise<boolean> {
112
+ async function ensureJFL(): Promise<boolean> {
130
113
  try {
131
- await execAsync("jfl --version")
114
+ await execAsync("jfl --version", { timeout: 5000 })
132
115
  return true
133
116
  } catch {
134
- return false
117
+ try {
118
+ await execAsync("npm install -g jfl", { timeout: 60000 })
119
+ return true
120
+ } catch {
121
+ return false
122
+ }
135
123
  }
136
124
  }
137
125
 
138
- /**
139
- * Find GTMs on this machine
140
- *
141
- * A GTM has: .jfl/ + knowledge/ + CLAUDE.md
142
- * Product repos (jfl-cli, jfl-platform) have .jfl/ but aren't GTMs
143
- */
144
- async function findGTMs(): Promise<GTM[]> {
145
- const gtms: GTM[] = []
126
+ function agentId(ctx: Context): string {
127
+ return `clawd-${ctx.platform}-${ctx.userId}`.slice(0, 30)
128
+ }
146
129
 
147
- // Check common locations
148
- const searchPaths = [
149
- join(homedir(), "CascadeProjects"),
150
- join(homedir(), "Projects"),
151
- join(homedir(), "code"),
152
- ]
130
+ // ============================================================================
131
+ // Welcome / Intro (shown on first use or install)
132
+ // ============================================================================
153
133
 
154
- for (const basePath of searchPaths) {
155
- if (!existsSync(basePath)) continue
134
+ const WELCOME_MESSAGE = `JFL - Just Fucking Launch
156
135
 
157
- try {
158
- const { stdout } = await execAsync(`find ${basePath} -maxdepth 2 -name .jfl -type d`)
159
- const dirs = stdout.trim().split("\n").filter(Boolean)
136
+ Your project context layer. I track decisions, save context, and keep everything synced across sessions.
160
137
 
161
- for (const dir of dirs) {
162
- const gtmPath = dir.replace("/.jfl", "")
138
+ What I do when active:
139
+ - Search project context before responding
140
+ - Capture decisions into the journal automatically
141
+ - Auto-commit your work every 2 minutes
142
+ - Coordinate with other agents on the team
163
143
 
164
- // Filter: Must have knowledge/ and CLAUDE.md to be a GTM
165
- const hasKnowledge = existsSync(join(gtmPath, "knowledge"))
166
- const hasClaude = existsSync(join(gtmPath, "CLAUDE.md"))
144
+ Commands:
145
+ /jfl Activate / select a project
146
+ /context <query> Search project knowledge
147
+ /journal <type> <title> | <summary> — Log work
148
+ /hud — Project dashboard
167
149
 
168
- if (!hasKnowledge || !hasClaude) {
169
- continue // Skip product repos
170
- }
150
+ To get started, use /jfl to pick a project.`
171
151
 
172
- const name = gtmPath.split("/").pop() || "unknown"
173
- gtms.push({ name, path: gtmPath })
174
- }
175
- } catch {
176
- // Skip if find fails
177
- }
178
- }
152
+ const ACTIVATION_MESSAGE = (gtmName: string) => `JFL activated: ${gtmName}
179
153
 
180
- return gtms
181
- }
154
+ I'll now:
155
+ - Search context before each response
156
+ - Capture decisions automatically
157
+ - Auto-commit work periodically
182
158
 
183
- /**
184
- * Session storage (persisted to disk)
185
- */
186
- const SESSIONS_FILE = join(homedir(), ".clawd", "memory", "jfl-sessions.json")
159
+ Commands:
160
+ /context <query> Search project context
161
+ /journal <type> <title> | <summary> — Log work
162
+ /hud Project dashboard
163
+ /jfl — Status`
187
164
 
188
- interface SessionData {
189
- gtmPath: string
190
- gtmName: string
191
- sessionId: string
192
- platform: string
193
- }
165
+ // ============================================================================
166
+ // Boot (on /jfl command)
167
+ // ============================================================================
194
168
 
195
- function loadSessions(): Map<string, SessionData> {
196
- try {
197
- if (existsSync(SESSIONS_FILE)) {
198
- const data = JSON.parse(readFileSync(SESSIONS_FILE, "utf-8"))
199
- return new Map(Object.entries(data))
169
+ export async function onBoot(ctx: Context) {
170
+ const hasJFL = await ensureJFL()
171
+ if (!hasJFL) {
172
+ return {
173
+ text: "JFL CLI not found and auto-install failed.\n\nInstall manually:\n npm install -g jfl\n\nThen run /jfl again.",
200
174
  }
201
- } catch {
202
- // Ignore parse errors
203
175
  }
204
- return new Map()
205
- }
206
176
 
207
- function saveSessions(sessions: Map<string, SessionData>) {
208
- try {
209
- const data = Object.fromEntries(sessions)
210
- const fs = require("fs")
211
- fs.mkdirSync(join(homedir(), ".clawd", "memory"), { recursive: true })
212
- fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2))
213
- } catch (error) {
214
- console.error("Failed to save sessions:", error)
177
+ // Check if already activated
178
+ const existing = getState(ctx.threadId)
179
+ if (existing?.activated && existing?.sessionBranch) {
180
+ return await showStatus(existing)
215
181
  }
216
- }
217
182
 
218
- const sessions = loadSessions()
183
+ // Find available GTMs
184
+ const gtms = await findGTMs()
185
+ if (gtms.length === 0) {
186
+ return {
187
+ text: "No GTM workspaces found.\n\nCreate one:\n jfl init -n 'My Project'\n\nThen run /jfl again.",
188
+ }
189
+ }
219
190
 
220
- function getSession(threadId: string) {
221
- return sessions.get(threadId)
222
- }
191
+ // Single GTM → auto-activate
192
+ if (gtms.length === 1) {
193
+ return await activateGTM(gtms[0].path, ctx)
194
+ }
223
195
 
224
- function setSession(threadId: string, data: SessionData) {
225
- sessions.set(threadId, data)
226
- saveSessions(sessions)
196
+ // Multiple GTMs let user pick
197
+ const buttons = gtms.map((g) => ({
198
+ text: g.name,
199
+ callbackData: `select:${g.path}`,
200
+ }))
201
+
202
+ return {
203
+ text: "Select a project:",
204
+ buttons,
205
+ }
227
206
  }
228
207
 
229
- /**
230
- * Handle GTM selection
231
- */
232
- async function handleSelectGTM(gtmPath: string, ctx: Context) {
233
- const gtmName = gtmPath.split("/").pop() || "unknown"
208
+ // ============================================================================
209
+ // Activation
210
+ // ============================================================================
211
+
212
+ async function activateGTM(gtmPath: string, ctx: Context) {
213
+ const agent = agentId(ctx)
234
214
 
215
+ // Read GTM name from config
216
+ let gtmName = gtmPath.split("/").pop() || "unknown"
235
217
  try {
236
- // Create or get session with proper worktree isolation
237
- const { stdout } = await execAsync(
238
- `cd ${gtmPath} && jfl session create --platform ${ctx.platform} --thread ${ctx.threadId}`
239
- )
240
- const sessionId = stdout.trim()
218
+ const cfg = JSON.parse(readFileSync(join(gtmPath, ".jfl", "config.json"), "utf-8"))
219
+ if (cfg.name) gtmName = cfg.name
220
+ } catch {}
221
+
222
+ try {
223
+ // Register agent with GTM (idempotent)
224
+ await openclawJSON(`register -g "${gtmPath}" -a ${agent}`, gtmPath)
225
+
226
+ // Ensure context hub is running from the GTM directory
227
+ await ensureContextHub(gtmPath)
228
+
229
+ // Start session
230
+ const session = await openclawJSON(`session-start -a ${agent} -g "${gtmPath}"`, gtmPath)
241
231
 
242
- // Store session with ID
243
- setSession(ctx.threadId, {
232
+ setState(ctx.threadId, {
244
233
  gtmPath,
245
234
  gtmName,
246
- sessionId,
247
- platform: ctx.platform
235
+ agentId: agent,
236
+ sessionBranch: session.session_id,
237
+ activated: true,
248
238
  })
249
239
 
250
- // Show command menu
251
- const commands = [
240
+ return { text: ACTIVATION_MESSAGE(gtmName) }
241
+ } catch (error: any) {
242
+ // Session start failed but registration may have worked
243
+ setState(ctx.threadId, {
244
+ gtmPath,
245
+ gtmName,
246
+ agentId: agent,
247
+ sessionBranch: null,
248
+ activated: true,
249
+ })
250
+ return { text: `JFL activated: ${gtmName}\n\nSession start failed: ${error.message}\nContext and journaling still work.` }
251
+ }
252
+ }
253
+
254
+ // ============================================================================
255
+ // Status (shown when /jfl is called while active)
256
+ // ============================================================================
257
+
258
+ async function showStatus(s: SessionState) {
259
+ let hubStatus = "unknown"
260
+ try {
261
+ const status = await openclawJSON("status")
262
+ hubStatus = status.context_hub?.healthy ? "running" : "offline"
263
+ } catch {
264
+ hubStatus = "offline"
265
+ }
266
+
267
+ return {
268
+ text: [
269
+ `JFL active: ${s.gtmName}`,
270
+ `Session: ${s.sessionBranch || "none"}`,
271
+ `Context Hub: ${hubStatus}`,
272
+ ``,
273
+ `Commands:`,
274
+ `/context <query> — Search`,
275
+ `/journal <type> <title> | <summary> — Log`,
276
+ `/hud — Dashboard`,
277
+ ].join("\n"),
278
+ buttons: [
252
279
  [
253
- { text: "📊 Dashboard", callbackData: "cmd:hud" },
254
- { text: "👥 CRM", callbackData: "cmd:crm" }
280
+ { text: "Dashboard", callbackData: "cmd:hud" },
281
+ { text: "Search Context", callbackData: "cmd:context" },
255
282
  ],
256
283
  [
257
- { text: "🎨 Brand", callbackData: "cmd:brand" },
258
- { text: "✍️ Content", callbackData: "cmd:content" }
284
+ { text: "Switch Project", callbackData: "cmd:switch" },
285
+ { text: "End Session", callbackData: "cmd:end" },
259
286
  ],
260
- [
261
- { text: "🔄 Sync", callbackData: "cmd:sync" },
262
- { text: "📝 Status", callbackData: "cmd:status" }
263
- ]
264
- ]
287
+ ],
288
+ }
289
+ }
265
290
 
266
- return {
267
- text: `✓ Session created: ${gtmName}\n\n` +
268
- `Session ID: ${sessionId}\n` +
269
- `Isolated worktree with auto-commit enabled.\n\n` +
270
- `What do you want to do?`,
271
- buttons: commands
291
+ // ============================================================================
292
+ // Callbacks
293
+ // ============================================================================
294
+
295
+ export async function onCallback(data: string, ctx: Context) {
296
+ const [action, value] = data.split(":")
297
+
298
+ if (action === "select") return await activateGTM(value, ctx)
299
+ if (action === "cmd") return await handleCommand(value, ctx)
300
+
301
+ return { text: "Unknown action" }
302
+ }
303
+
304
+ async function handleCommand(cmd: string, ctx: Context) {
305
+ const s = getState(ctx.threadId)
306
+ if (!s?.activated) return { text: "JFL not active. Use /jfl to select a project." }
307
+
308
+ switch (cmd) {
309
+ case "hud":
310
+ return await runInGTM(s, "jfl hud")
311
+ case "crm":
312
+ return showCRMMenu()
313
+ case "context":
314
+ return await runContext(s)
315
+ case "status":
316
+ return await showStatus(s)
317
+ case "brand":
318
+ return showBrandMenu()
319
+ case "content":
320
+ return showContentMenu()
321
+ case "switch": {
322
+ // Deactivate current
323
+ setState(ctx.threadId, { ...s, activated: false, sessionBranch: null })
324
+ try { await openclawJSON("session-end --sync") } catch {}
325
+ return await onBoot(ctx)
272
326
  }
273
- } catch (error: any) {
274
- return {
275
- text: `❌ Failed to create session\n\n${error.message}\n\n` +
276
- `Make sure you're in a JFL GTM directory.`
327
+ case "end": {
328
+ try { await openclawJSON("session-end --sync") } catch {}
329
+ setState(ctx.threadId, { ...s, activated: false, sessionBranch: null })
330
+ return { text: `Session ended for ${s.gtmName}.\n\nUse /jfl to start again.` }
277
331
  }
332
+ default:
333
+ return { text: "Unknown command" }
278
334
  }
279
335
  }
280
336
 
281
- /**
282
- * Handle command button
283
- */
284
- async function handleCommand(cmd: string, ctx: Context) {
285
- const session = getSession(ctx.threadId)
337
+ // ============================================================================
338
+ // Commands
339
+ // ============================================================================
286
340
 
287
- if (!session) {
288
- return { text: "⚠️ Session expired. Run /jfl to select a GTM." }
289
- }
341
+ export async function onCommand(cmd: string, args: string[], ctx: Context) {
342
+ if (cmd === "jfl" || cmd === "gtm") return await onBoot(ctx)
343
+
344
+ const s = getState(ctx.threadId)
345
+ if (!s?.activated) return { text: "JFL not active. Use /jfl to select a project first." }
290
346
 
291
347
  switch (cmd) {
292
348
  case "hud":
293
- return await runJFLCommand(session, "hud")
349
+ return await runInGTM(s, "jfl hud")
294
350
 
295
351
  case "crm":
296
- return showCRMMenu()
352
+ if (args.length === 0) return showCRMMenu()
353
+ return await runInGTM(s, `./crm ${args.join(" ")}`)
354
+
355
+ case "context":
356
+ if (args.length === 0) return await runContext(s)
357
+ return await runContextQuery(s, args.join(" "))
358
+
359
+ case "journal": {
360
+ const type = args[0] || "discovery"
361
+ const title = args.slice(1).join(" ") || "Note"
362
+ await openclaw(`journal --type ${type} --title "${title}" --summary "${title}"`)
363
+ return { text: `Journal entry written: [${type}] ${title}` }
364
+ }
297
365
 
298
366
  case "brand":
299
367
  return showBrandMenu()
300
368
 
301
369
  case "content":
302
- return showContentMenu()
303
-
304
- case "sync":
305
- return await runGitSync(session)
306
-
307
- case "status":
308
- return await runGitStatus(session)
370
+ if (args.length === 0) return showContentMenu()
371
+ return await runInGTM(s, `jfl content ${args.join(" ")}`)
309
372
 
310
373
  default:
311
- return { text: "Unknown command" }
374
+ return { text: `Unknown command: /${cmd}` }
312
375
  }
313
376
  }
314
377
 
315
- /**
316
- * Run JFL command (uses session exec for isolation)
317
- */
318
- async function runJFLCommand(session: SessionData, command: string) {
319
- try {
320
- // Use jfl session exec to run command in isolated worktree
321
- const { stdout, stderr } = await execAsync(
322
- `cd ${session.gtmPath} && jfl session exec "${session.sessionId}" "${command}"`,
323
- {
324
- env: { ...process.env, JFL_PLATFORM: session.platform }
325
- }
326
- )
327
-
328
- const output = stdout || stderr
329
- const formatted = formatForTelegram(output)
378
+ // ============================================================================
379
+ // Runners
380
+ // ============================================================================
330
381
 
331
- return {
332
- text: formatted,
333
- parseMode: "Markdown"
334
- }
382
+ async function runInGTM(s: SessionState, command: string): Promise<any> {
383
+ try {
384
+ const { stdout } = await execAsync(command, {
385
+ cwd: s.gtmPath,
386
+ timeout: 30000,
387
+ env: { ...process.env },
388
+ })
389
+ return { text: stripAnsi(stdout).slice(0, 4000), parseMode: "Markdown" }
335
390
  } catch (error: any) {
336
- return {
337
- text: `❌ Error running jfl ${command}\n\n${error.message}`
338
- }
391
+ return { text: `Command failed: ${error.message}` }
339
392
  }
340
393
  }
341
394
 
342
- /**
343
- * Run CRM command (uses session exec for isolation)
344
- */
345
- async function runCRMCommand(session: SessionData, args: string[]) {
395
+ async function runContext(s: SessionState) {
346
396
  try {
347
- // Use session exec to run CRM in isolated worktree
348
- const { stdout } = await execAsync(
349
- `cd ${session.gtmPath} && jfl session exec "${session.sessionId}" "./crm ${args.join(" ")}"`
397
+ const items = await openclawJSON("context")
398
+ if (!Array.isArray(items) || items.length === 0) {
399
+ return { text: "No context items found. Context Hub may not be running.\n\nStart it: jfl context-hub start" }
400
+ }
401
+ const lines = items.slice(0, 8).map((i: any) =>
402
+ `[${i.source || i.type}] ${i.title}\n ${(i.content || "").slice(0, 100)}`
350
403
  )
351
- const formatted = formatForTelegram(stdout)
404
+ return { text: `Project Context\n\n${lines.join("\n\n")}` }
405
+ } catch (error: any) {
406
+ return { text: `Context Hub unreachable: ${error.message}` }
407
+ }
408
+ }
352
409
 
353
- return {
354
- text: formatted,
355
- parseMode: "Markdown"
410
+ async function runContextQuery(s: SessionState, query: string) {
411
+ try {
412
+ const items = await openclawJSON(`context -q "${query}"`)
413
+ if (!Array.isArray(items) || items.length === 0) {
414
+ return { text: `No results for: ${query}` }
356
415
  }
416
+ const lines = items.slice(0, 5).map((i: any) =>
417
+ `[${i.source || i.type}] ${i.title}\n ${(i.content || "").slice(0, 120)}`
418
+ )
419
+ return { text: `Results for "${query}"\n\n${lines.join("\n\n")}` }
357
420
  } catch (error: any) {
358
- return {
359
- text: `❌ Error running crm\n\n${error.message}`
360
- }
421
+ return { text: `Search failed: ${error.message}` }
361
422
  }
362
423
  }
363
424
 
364
- /**
365
- * Show CRM menu
366
- */
425
+ // ============================================================================
426
+ // Menus
427
+ // ============================================================================
428
+
367
429
  function showCRMMenu() {
368
430
  return {
369
- text: "👥 CRM\n\nWhat do you want to do?",
431
+ text: "CRM\n\nWhat do you want to do?",
370
432
  buttons: [
371
433
  [
372
- { text: "📋 List Pipeline", callbackData: "crm:list" },
373
- { text: "Stale Deals", callbackData: "crm:stale" }
434
+ { text: "Pipeline", callbackData: "crm:list" },
435
+ { text: "Stale Deals", callbackData: "crm:stale" },
374
436
  ],
375
437
  [
376
- { text: "📞 Prep Call", callbackData: "crm:prep" },
377
- { text: "✏️ Log Touch", callbackData: "crm:touch" }
378
- ]
379
- ]
438
+ { text: "Prep Call", callbackData: "crm:prep" },
439
+ { text: "Log Touch", callbackData: "crm:touch" },
440
+ ],
441
+ ],
380
442
  }
381
443
  }
382
444
 
383
- /**
384
- * Show brand menu
385
- */
386
445
  function showBrandMenu() {
387
446
  return {
388
- text: "🎨 Brand Architect\n\nWhat do you want to create?",
447
+ text: "Brand Architect\n\nWhat do you want to create?",
389
448
  buttons: [
390
449
  [
391
450
  { text: "Logo Marks", callbackData: "brand:marks" },
392
- { text: "Color Palette", callbackData: "brand:colors" }
451
+ { text: "Colors", callbackData: "brand:colors" },
393
452
  ],
394
453
  [
395
454
  { text: "Typography", callbackData: "brand:typography" },
396
- { text: "Full System", callbackData: "brand:full" }
397
- ]
398
- ]
455
+ { text: "Full System", callbackData: "brand:full" },
456
+ ],
457
+ ],
399
458
  }
400
459
  }
401
460
 
402
- /**
403
- * Show content menu
404
- */
405
461
  function showContentMenu() {
406
462
  return {
407
- text: "✍️ Content Creator\n\nWhat do you want to create?",
463
+ text: "Content Creator\n\nWhat do you want to create?",
408
464
  buttons: [
409
465
  [
410
- { text: "Twitter Thread", callbackData: "content:thread" },
411
- { text: "Single Post", callbackData: "content:post" }
466
+ { text: "Thread", callbackData: "content:thread" },
467
+ { text: "Post", callbackData: "content:post" },
412
468
  ],
413
469
  [
414
470
  { text: "Article", callbackData: "content:article" },
415
- { text: "One-Pager", callbackData: "content:onepager" }
416
- ]
417
- ]
471
+ { text: "One-Pager", callbackData: "content:onepager" },
472
+ ],
473
+ ],
474
+ }
475
+ }
476
+
477
+ // ============================================================================
478
+ // GTM discovery (only returns type:"gtm", never services)
479
+ // ============================================================================
480
+
481
+ function readJflConfig(dir: string): { type?: string; name?: string } | null {
482
+ try {
483
+ return JSON.parse(readFileSync(join(dir, ".jfl", "config.json"), "utf-8"))
484
+ } catch {
485
+ return null
486
+ }
487
+ }
488
+
489
+ async function findGTMs(): Promise<GTM[]> {
490
+ // First try OpenClaw registry
491
+ try {
492
+ const gtms = await openclawJSON("gtm-list")
493
+ if (Array.isArray(gtms) && gtms.length > 0) {
494
+ return gtms.map((g: any) => ({ name: g.name, path: g.path }))
495
+ }
496
+ } catch { /* fall through to filesystem scan */ }
497
+
498
+ // Fallback: scan filesystem for type:"gtm" directories
499
+ const gtms: GTM[] = []
500
+ const searchPaths = [
501
+ join(homedir(), "CascadeProjects"),
502
+ join(homedir(), "Projects"),
503
+ join(homedir(), "code"),
504
+ ]
505
+
506
+ for (const basePath of searchPaths) {
507
+ if (!existsSync(basePath)) continue
508
+ try {
509
+ const entries = readdirSync(basePath, { withFileTypes: true })
510
+ for (const entry of entries) {
511
+ if (!entry.isDirectory()) continue
512
+ const candidate = join(basePath, entry.name)
513
+ const config = readJflConfig(candidate)
514
+ if (!config) continue
515
+
516
+ // Only include GTMs, not services
517
+ if (config.type === "gtm") {
518
+ gtms.push({ name: config.name || entry.name, path: candidate })
519
+ } else if (!config.type && existsSync(join(candidate, "knowledge"))) {
520
+ // Legacy: no type field but has knowledge/ → probably a GTM
521
+ gtms.push({ name: config.name || entry.name, path: candidate })
522
+ }
523
+ // type:"service" → skip entirely
524
+ }
525
+ } catch { /* skip */ }
418
526
  }
527
+
528
+ return gtms
419
529
  }
420
530
 
531
+ // ============================================================================
532
+ // LIFECYCLE HOOKS (Clawdbot calls these automatically)
533
+ // All hooks check activation state. When dormant, they're no-ops.
534
+ // ============================================================================
535
+
421
536
  /**
422
- * Git sync (runs in session worktree)
537
+ * session_start fires when Clawdbot session begins.
538
+ * Does NOT auto-activate. Just checks if JFL is available and notes it.
539
+ * User must run /jfl to activate.
423
540
  */
424
- async function runGitSync(session: SessionData) {
425
- try {
426
- // Session exec automatically runs session-sync.sh before command
427
- const { stdout } = await execAsync(
428
- `cd ${session.gtmPath} && jfl session exec "${session.sessionId}" "git status --short"`
429
- )
541
+ export async function onSessionStart(event: { agentId: string; platform: string; threadId: string }) {
542
+ const existing = getState(event.threadId)
430
543
 
431
- return {
432
- text: `✓ Synced ${session.gtmName}\n\nSession-sync runs automatically before each command.\n\n${stdout || "Working tree clean"}`
544
+ // If already activated from a previous session, re-inject context
545
+ if (existing?.activated && existing?.gtmPath) {
546
+ const agent = existing.agentId || event.agentId || `clawd-${event.platform}`
547
+
548
+ try {
549
+ // Ensure context hub is running from the GTM directory
550
+ await ensureContextHub(existing.gtmPath)
551
+
552
+ // Try to resume or start a new session
553
+ const session = await openclawJSON(`session-start -a ${agent} -g "${existing.gtmPath}"`, existing.gtmPath)
554
+ setState(event.threadId, { ...existing, sessionBranch: session.session_id })
555
+
556
+ return {
557
+ prependContext: [
558
+ `<jfl-session>`,
559
+ `GTM: ${existing.gtmName} (${existing.gtmPath})`,
560
+ `Session: ${session.session_id}`,
561
+ `Use /jfl for status. Use /context <query> to search.`,
562
+ `</jfl-session>`,
563
+ ].join("\n"),
564
+ }
565
+ } catch {
566
+ // Session start failed but we're still "activated" for the GTM
567
+ return {
568
+ prependContext: `<jfl-session>GTM: ${existing.gtmName} (session start failed, commands still work)</jfl-session>`,
569
+ }
433
570
  }
434
- } catch (error: any) {
435
- return {
436
- text: `❌ Sync failed\n\n${error.message}`
571
+ }
572
+
573
+ // Not activated — stay quiet. Don't inject anything.
574
+ // Just check if JFL is available for the welcome message later
575
+ const hasJFL = await ensureJFL()
576
+ if (hasJFL) {
577
+ const gtms = await findGTMs()
578
+ if (gtms.length > 0) {
579
+ return {
580
+ prependContext: `<jfl-available>JFL is installed with ${gtms.length} project(s) available. User can activate with /jfl.</jfl-available>`,
581
+ }
437
582
  }
438
583
  }
584
+
585
+ return { prependContext: "" }
439
586
  }
440
587
 
441
588
  /**
442
- * Git status (runs in session worktree)
589
+ * before_turn fires before each agent response.
590
+ * Only injects context when JFL is active.
443
591
  */
444
- async function runGitStatus(session: SessionData) {
592
+ export async function onBeforeTurn(event: { agentId: string; threadId: string; message?: string }) {
593
+ const s = getState(event.threadId)
594
+ if (!s?.activated || !s?.sessionBranch || !s?.gtmPath) return { prependContext: "" }
595
+
596
+ let contextBlock = ""
445
597
  try {
446
- // Use session exec to check status in isolated worktree
447
- const { stdout } = await execAsync(
448
- `cd ${session.gtmPath} && jfl session exec "${session.sessionId}" "git status --short && echo '---' && git log --oneline -5"`
449
- )
598
+ const query = event.message?.slice(0, 100) || ""
599
+ if (query.length > 10) {
600
+ const items = await openclawJSON(`context -q "${query.replace(/"/g, '\\"')}"`, s.gtmPath)
601
+ if (Array.isArray(items) && items.length > 0) {
602
+ const relevant = items.slice(0, 3).map((i: any) =>
603
+ `- [${i.type}] ${i.title}: ${(i.content || "").slice(0, 150)}`
604
+ ).join("\n")
605
+ contextBlock = `<jfl-context>\nRelevant context:\n${relevant}\n</jfl-context>`
606
+ }
607
+ }
608
+ } catch { /* non-fatal */ }
450
609
 
451
- return {
452
- text: `📊 Status: ${session.gtmName}\nSession: ${session.sessionId}\n\n\`\`\`\n${stdout}\n\`\`\``,
453
- parseMode: "Markdown"
610
+ return { prependContext: contextBlock }
611
+ }
612
+
613
+ /**
614
+ * after_turn — fires after each agent response.
615
+ * Only runs heartbeat + capture when JFL is active.
616
+ */
617
+ export async function onAfterTurn(event: {
618
+ agentId: string
619
+ threadId: string
620
+ response?: string
621
+ detectedIntent?: string
622
+ }) {
623
+ const s = getState(event.threadId)
624
+ if (!s?.activated || !s?.sessionBranch || !s?.gtmPath) return
625
+
626
+ // Heartbeat (auto-commit)
627
+ try {
628
+ await openclaw("heartbeat --json", s.gtmPath)
629
+ } catch { /* non-fatal */ }
630
+
631
+ // Auto-capture decisions/completions
632
+ if (event.detectedIntent && event.response) {
633
+ const intentMap: Record<string, string> = {
634
+ decision: "decision",
635
+ completed: "feature",
636
+ fixed: "fix",
637
+ learned: "discovery",
454
638
  }
455
- } catch (error: any) {
456
- return {
457
- text: `❌ Status failed\n\n${error.message}`
639
+ const type = intentMap[event.detectedIntent]
640
+ if (type) {
641
+ const title = event.response.slice(0, 80).replace(/\n/g, " ")
642
+ try {
643
+ await openclaw(`journal --type ${type} --title "${title.replace(/"/g, '\\"')}" --summary "${title.replace(/"/g, '\\"')}"`, s.gtmPath)
644
+ } catch { /* non-fatal */ }
458
645
  }
459
646
  }
460
647
  }
461
648
 
462
649
  /**
463
- * Format CLI output for Telegram
464
- * Optimized for mobile viewing
650
+ * session_end fires when Clawdbot session ends.
651
+ * Only cleans up if JFL was active.
465
652
  */
466
- function formatForTelegram(output: string): string {
467
- return output
468
- // Strip ANSI color codes
469
- .replace(/\x1b\[[0-9;]*m/g, "")
470
- // Convert long separator lines to short ones (mobile-friendly)
471
- .replace(/[━─]{20,}/g, "━━━━━━━━━")
472
- // Keep single box-drawing characters as-is (they render fine)
473
- .replace(/[┌┐└┘├┤┬┴┼]/g, "")
474
- // Convert vertical bars
475
- .replace(/[│┃]/g, "")
476
- // Add spacing around emoji headers for readability
477
- .replace(/^(📊|👥|🎨|✍️|🔄|📝)/gm, "\n$1")
478
- // Preserve emoji and structure
479
- .trim()
653
+ export async function onSessionEnd(event: { agentId: string; threadId: string }) {
654
+ const s = getState(event.threadId)
655
+ if (!s?.activated || !s?.sessionBranch || !s?.gtmPath) return
656
+
657
+ try {
658
+ await openclawJSON("session-end --sync", s.gtmPath)
659
+ } catch { /* never block shutdown */ }
660
+
661
+ // Keep activated state but clear session
662
+ setState(event.threadId, { ...s, sessionBranch: null })
663
+ }
664
+
665
+ // ============================================================================
666
+ // Util
667
+ // ============================================================================
668
+
669
+ function stripAnsi(str: string): string {
670
+ return str.replace(/\x1b\[[0-9;]*m/g, "")
480
671
  }
481
672
 
482
673
  export default {
674
+ // Interactive (user-triggered)
483
675
  onBoot,
484
676
  onCallback,
485
- onCommand
677
+ onCommand,
678
+ // Lifecycle (Clawdbot-triggered, gated by activation)
679
+ onSessionStart,
680
+ onBeforeTurn,
681
+ onAfterTurn,
682
+ onSessionEnd,
486
683
  }