jfl 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jfl",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Just Fucking Launch - CLI for AI-powered GTM and development",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,6 +14,12 @@ import { execSync } from "child_process"
14
14
  import type { PiContext, PiTheme } from "./types.js"
15
15
  import { hudRenderCall, hudRenderResult } from "./tool-renderers.js"
16
16
 
17
+ function clipLine(text: string, maxW: number): string {
18
+ const stripped = text.replace(/\x1b\[[0-9;]*m/g, "")
19
+ if (stripped.length <= maxW) return text
20
+ return text.slice(0, maxW - 1) + "…"
21
+ }
22
+
17
23
  let projectRoot = ""
18
24
 
19
25
  function getDaysToLaunch(root: string): string | null {
@@ -51,6 +57,22 @@ function getProjectPhase(root: string): string {
51
57
  return "unknown"
52
58
  }
53
59
 
60
+ function getLastJournalTitle(root: string): string | null {
61
+ try {
62
+ const journalDir = join(root, ".jfl", "journal")
63
+ if (!existsSync(journalDir)) return null
64
+ const files = execSync(`ls -t "${journalDir}"/*.jsonl 2>/dev/null | head -1`, {
65
+ cwd: root, timeout: 3000, encoding: "utf-8",
66
+ }).trim()
67
+ if (!files) return null
68
+ const content = readFileSync(files, "utf-8").trim()
69
+ const lastLine = content.split("\n").pop()
70
+ if (!lastLine) return null
71
+ const entry = JSON.parse(lastLine)
72
+ return entry.title ?? null
73
+ } catch { return null }
74
+ }
75
+
54
76
  function buildHudLines(root: string): string[] {
55
77
  const configPath = join(root, ".jfl", "config.json")
56
78
  let projectName = root.split("/").pop() ?? "JFL"
@@ -66,41 +88,30 @@ function buildHudLines(root: string): string[] {
66
88
 
67
89
  const days = getDaysToLaunch(root)
68
90
  const phase = getProjectPhase(root)
91
+ const lastWork = getLastJournalTitle(root)
69
92
 
70
93
  const lines = [
71
- `◆ ${projectName} [${projectType}]`,
94
+ `◆ ${projectName}`,
95
+ phase !== "unknown" ? ` ${phase}` : "",
72
96
  days ? ` ${days}` : "",
73
- phase !== "unknown" ? ` Phase: ${phase}` : "",
97
+ lastWork ? ` Last: ${lastWork.slice(0, 60)}` : "",
74
98
  ].filter(Boolean)
75
99
 
76
- try {
77
- const crmOutput = execSync("./crm list --compact 2>/dev/null | head -3", {
78
- cwd: root,
79
- timeout: 5000,
80
- encoding: "utf-8",
81
- }).trim()
82
- if (crmOutput) {
83
- lines.push(" Pipeline:", ...crmOutput.split("\n").map(l => ` ${l}`))
84
- }
85
- } catch {}
86
-
87
100
  return lines
88
101
  }
89
102
 
90
103
  export function setupHudTool(ctx: PiContext): void {
91
104
  projectRoot = ctx.session.projectRoot
92
105
 
93
- // Themed widget above editor
94
106
  ctx.ui.setWidget("jfl-hud", (_tui: any, theme: PiTheme) => {
95
107
  const lines = buildHudLines(projectRoot)
96
108
  const themed = lines.map((line, i) => {
97
- if (i === 0) return theme.fg("accent", line)
98
- if (line.includes("Phase:")) return theme.fg("warning", line)
99
- if (line.includes("Pipeline")) return theme.fg("accent", line)
109
+ if (i === 0) return theme.fg("accent", theme.bold(line))
110
+ if (line.includes("Last:")) return theme.fg("dim", line)
100
111
  return theme.fg("muted", line)
101
112
  })
102
113
  return {
103
- render: () => themed,
114
+ render: (width: number) => themed.map(l => clipLine(l, width)),
104
115
  invalidate() {},
105
116
  }
106
117
  })
@@ -135,11 +146,10 @@ export async function updateHudWidget(ctx: PiContext): Promise<void> {
135
146
  ctx.ui.setWidget("jfl-hud", (_tui: any, theme: PiTheme) => {
136
147
  const lines = buildHudLines(projectRoot)
137
148
  const themed = lines.map((line, i) => {
138
- if (i === 0) return theme.fg("accent", line)
139
- if (line.includes("Phase:")) return theme.fg("warning", line)
140
- if (line.includes("Pipeline")) return theme.fg("accent", line)
149
+ if (i === 0) return theme.fg("accent", theme.bold(line))
150
+ if (line.includes("Last:")) return theme.fg("dim", line)
141
151
  return theme.fg("muted", line)
142
152
  })
143
- return { render: () => themed, invalidate() {} }
153
+ return { render: (width: number) => themed.map(l => clipLine(l, width)), invalidate() {} }
144
154
  })
145
155
  }
@@ -317,24 +317,26 @@ export default async function jflExtension(pi: any): Promise<void> {
317
317
 
318
318
  ctx.log(`JFL: ${projectName} — session ready`)
319
319
 
320
- if (config.pi?.auto_start !== false && pi.sendUserMessage) {
320
+ if (config.pi?.auto_start !== false && pi.sendMessage) {
321
321
  setTimeout(() => {
322
- pi.sendUserMessage([
323
- `JFL session started in "${projectName}" on branch ${ctx.session.branch}.`,
324
- "",
325
- "Complete these steps before responding to the user:",
326
- "1. Use jfl_context to get recent project context (journal, knowledge, decisions)",
327
- "2. Use jfl_memory_search to check for any recent decisions or blockers",
328
- "3. Use jfl_hud to get the project dashboard (timeline, phase, pipeline)",
329
- "4. Show a brief status update with:",
330
- " - What was worked on recently (from journal)",
331
- " - Current phase and focus",
332
- " - Any blocking issues or warnings",
333
- " - Suggested next action",
334
- "",
335
- "Follow the CLAUDE.md instructions injected in your system prompt.",
336
- "Write journal entries as you work. Capture decisions immediately.",
337
- ].join("\n"))
322
+ pi.sendMessage({
323
+ customType: "jfl-session-init",
324
+ content: [
325
+ `JFL session ready: "${projectName}" on branch ${ctx.session.branch}.`,
326
+ "",
327
+ "You have full project context injected in your system prompt (CLAUDE.md + recent journal + knowledge docs).",
328
+ "Tools available: jfl_context, jfl_memory_search, jfl_hud, jfl_journal.",
329
+ "",
330
+ "Greet the user naturally with a brief status:",
331
+ "- What was worked on recently (from your injected context)",
332
+ "- Current phase and any blockers",
333
+ "- A suggested next action",
334
+ "",
335
+ "Keep it to 3-5 lines. No setup noise. Just be ready to work.",
336
+ "Write journal entries as you work. Capture decisions immediately.",
337
+ ].join("\n"),
338
+ display: false,
339
+ }, { triggerTurn: true, deliverAs: "steer" })
338
340
  }, 500)
339
341
  }
340
342
  })
@@ -92,41 +92,38 @@ export function contextRenderCall(args: Record<string, any>, theme: PiTheme): an
92
92
  }
93
93
 
94
94
  export function contextRenderResult(result: any, opts: { expanded: boolean }, theme: PiTheme): any {
95
- const MAX_W = 140
96
95
  const raw = extractText(result)
97
- if (raw === "No relevant context found." || raw === "No context available.") {
98
- return { render: () => safeRender([theme.fg("dim", "No relevant context found")]), invalidate() {} }
96
+ if (raw === "No relevant context found." || raw === "No context available." || raw === "Context Hub unavailable.") {
97
+ return { render: () => safeRender([theme.fg("dim", "No context available")]), invalidate() {} }
99
98
  }
100
99
 
101
100
  const sections = raw.split(/---\n/).filter(Boolean)
102
- const lines: string[] = []
101
+ const lineCount = raw.split("\n").length
103
102
 
104
- const max = opts.expanded ? sections.length : Math.min(sections.length, 3)
105
- for (let i = 0; i < max; i++) {
106
- const section = sections[i].trim()
107
- const firstLine = section.split("\n")[0] ?? ""
103
+ // Collapsed: one-line summary
104
+ if (!opts.expanded) {
105
+ const summary = theme.fg("success", "✓ ") + theme.fg("muted", `${sections.length} items, ${lineCount} lines loaded (Ctrl+O to expand)`)
106
+ return { render: () => safeRender([summary]), invalidate() {} }
107
+ }
108
108
 
109
+ // Expanded: show sections with truncation
110
+ const lines: string[] = []
111
+ for (const section of sections) {
112
+ const firstLine = section.trim().split("\n")[0] ?? ""
109
113
  if (firstLine.startsWith("[")) {
110
114
  const typeMatch = firstLine.match(/^\[(\w+)\]\s*(.*)/)
111
115
  if (typeMatch) {
112
116
  const typeColor = typeMatch[1] === "decision" ? "warning" : typeMatch[1] === "feature" ? "success" : "muted"
113
- lines.push(truncLine(`${theme.fg(typeColor, `[${typeMatch[1]}]`)} ${theme.fg("text", typeMatch[2])}`, MAX_W))
117
+ lines.push(truncLine(`${theme.fg(typeColor, `[${typeMatch[1]}]`)} ${theme.fg("text", typeMatch[2])}`))
114
118
  } else {
115
- lines.push(truncLine(theme.fg("text", firstLine), MAX_W))
119
+ lines.push(truncLine(theme.fg("text", firstLine)))
116
120
  }
117
121
  } else {
118
- lines.push(truncLine(theme.fg("text", firstLine), MAX_W))
119
- }
120
-
121
- if (opts.expanded) {
122
- const rest = section.split("\n").slice(1)
123
- for (const l of rest) lines.push(truncLine(theme.fg("muted", l), MAX_W))
124
- lines.push("")
122
+ lines.push(truncLine(theme.fg("text", firstLine)))
125
123
  }
126
- }
127
-
128
- if (!opts.expanded && sections.length > 3) {
129
- lines.push(theme.fg("dim", `... ${sections.length - 3} more results (Ctrl+O)`))
124
+ const rest = section.trim().split("\n").slice(1)
125
+ for (const l of rest) lines.push(truncLine(theme.fg("muted", l)))
126
+ lines.push("")
130
127
  }
131
128
 
132
129
  return { render: () => safeRender(lines), invalidate() {} }
@@ -177,27 +174,24 @@ export function memoryRenderCall(args: Record<string, any>, theme: PiTheme): any
177
174
  export function memoryRenderResult(result: any, opts: { expanded: boolean }, theme: PiTheme): any {
178
175
  const raw = extractText(result)
179
176
  if (raw.includes("unavailable") || raw.includes("No memories")) {
180
- return { render: () => safeRender([theme.fg("dim", raw)]), invalidate() {} }
177
+ return { render: () => safeRender([theme.fg("dim", "No memories found")]), invalidate() {} }
181
178
  }
182
179
 
183
180
  const entries = raw.split(/\n\n---\n\n/).filter(Boolean)
184
- const lines: string[] = []
185
181
 
186
- const max = opts.expanded ? entries.length : Math.min(entries.length, 4)
187
- for (let i = 0; i < max; i++) {
188
- const entry = entries[i].trim()
189
- const [header, ...body] = entry.split("\n")
190
- if (header) lines.push(theme.fg("accent", header))
191
- if (opts.expanded) {
192
- for (const l of body) lines.push(theme.fg("muted", l))
193
- lines.push("")
194
- } else if (body.length > 0) {
195
- lines.push(theme.fg("dim", body[0].slice(0, 80) + (body[0].length > 80 ? "…" : "")))
196
- }
182
+ // Collapsed: one-line summary
183
+ if (!opts.expanded) {
184
+ const summary = theme.fg("success", "✓ ") + theme.fg("muted", `${entries.length} memories found (Ctrl+O to expand)`)
185
+ return { render: () => safeRender([summary]), invalidate() {} }
197
186
  }
198
187
 
199
- if (!opts.expanded && entries.length > 4) {
200
- lines.push(theme.fg("dim", `... ${entries.length - 4} more (Ctrl+O)`))
188
+ // Expanded: show entries
189
+ const lines: string[] = []
190
+ for (const entry of entries) {
191
+ const [header, ...body] = entry.trim().split("\n")
192
+ if (header) lines.push(truncLine(theme.fg("accent", header)))
193
+ for (const l of body) lines.push(truncLine(theme.fg("muted", l)))
194
+ lines.push("")
201
195
  }
202
196
 
203
197
  return { render: () => safeRender(lines), invalidate() {} }