jfl 0.9.9 → 0.9.11

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 (54) hide show
  1. package/dist/commands/init-from-service.d.ts.map +1 -1
  2. package/dist/commands/init-from-service.js +2 -2
  3. package/dist/commands/init-from-service.js.map +1 -1
  4. package/dist/commands/init.d.ts.map +1 -1
  5. package/dist/commands/init.js +88 -23
  6. package/dist/commands/init.js.map +1 -1
  7. package/dist/commands/peter.d.ts.map +1 -1
  8. package/dist/commands/peter.js +112 -35
  9. package/dist/commands/peter.js.map +1 -1
  10. package/dist/commands/repair.d.ts.map +1 -1
  11. package/dist/commands/repair.js +13 -11
  12. package/dist/commands/repair.js.map +1 -1
  13. package/dist/commands/session.d.ts.map +1 -1
  14. package/dist/commands/session.js +7 -40
  15. package/dist/commands/session.js.map +1 -1
  16. package/dist/commands/start.js +3 -3
  17. package/dist/commands/start.js.map +1 -1
  18. package/dist/lib/agent-config.d.ts +1 -0
  19. package/dist/lib/agent-config.d.ts.map +1 -1
  20. package/dist/lib/agent-config.js.map +1 -1
  21. package/dist/lib/agent-guards.d.ts +67 -0
  22. package/dist/lib/agent-guards.d.ts.map +1 -0
  23. package/dist/lib/agent-guards.js +229 -0
  24. package/dist/lib/agent-guards.js.map +1 -0
  25. package/dist/lib/agent-runtime-api.d.ts +32 -0
  26. package/dist/lib/agent-runtime-api.d.ts.map +1 -0
  27. package/dist/lib/agent-runtime-api.js +270 -0
  28. package/dist/lib/agent-runtime-api.js.map +1 -0
  29. package/dist/lib/agent-session.d.ts.map +1 -1
  30. package/dist/lib/agent-session.js +255 -25
  31. package/dist/lib/agent-session.js.map +1 -1
  32. package/dist/lib/gtm-generator.js +3 -1
  33. package/dist/lib/gtm-generator.js.map +1 -1
  34. package/dist/lib/memory-search.d.ts.map +1 -1
  35. package/dist/lib/memory-search.js +0 -8
  36. package/dist/lib/memory-search.js.map +1 -1
  37. package/dist/utils/jfl-paths.d.ts +9 -0
  38. package/dist/utils/jfl-paths.d.ts.map +1 -1
  39. package/dist/utils/jfl-paths.js +13 -0
  40. package/dist/utils/jfl-paths.js.map +1 -1
  41. package/package.json +1 -1
  42. package/packages/pi/dist/index.d.ts.map +1 -1
  43. package/packages/pi/dist/index.js +19 -1
  44. package/packages/pi/dist/index.js.map +1 -1
  45. package/packages/pi/dist/session.d.ts +5 -1
  46. package/packages/pi/dist/session.d.ts.map +1 -1
  47. package/packages/pi/dist/session.js +247 -116
  48. package/packages/pi/dist/session.js.map +1 -1
  49. package/packages/pi/extensions/index.ts +24 -1
  50. package/packages/pi/extensions/session.ts +256 -96
  51. package/packages/pi/skills/end/SKILL.md +8 -0
  52. package/scripts/session/session-cleanup.sh +19 -6
  53. package/template/.github/workflows/jfl-eval.yml +8 -1
  54. package/template/scripts/session/session-cleanup.sh +23 -8
@@ -3,26 +3,134 @@
3
3
  *
4
4
  * Pi-native session lifecycle via Context Hub API.
5
5
  * Calls POST /api/session/init on start and POST /api/session/end on shutdown.
6
- * Hub handles sync, branch creation, doctor — single source of truth.
6
+ * Hub handles sync, doctor, branch creation — single source of truth.
7
7
  * Pi only manages the auto-commit daemon locally (needs a detached process).
8
8
  *
9
+ * AGENT MODE: When JFL_AGENT_MODE=1 or --print is used (PP agent runs),
10
+ * skip ALL session branching. PP agents work in isolated /tmp worktrees —
11
+ * they must NOT touch the main repo's git state.
12
+ *
9
13
  * @purpose Pi session lifecycle — Hub API for init/end, local auto-commit daemon
10
14
  */
11
15
 
12
- import { execSync, spawn } from "child_process"
13
- import { existsSync, readFileSync, writeFileSync } from "fs"
16
+ import { execSync, spawn, exec } from "child_process"
17
+ import { promisify } from "util"
18
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs"
14
19
  import { join } from "path"
15
20
  import type { PiContext, JflConfig } from "./types.js"
16
21
  import { hubUrl, authToken } from "./map-bridge.js"
17
22
 
23
+ const execAsync = promisify(exec)
24
+
18
25
  let autoCommitProcess: ReturnType<typeof spawn> | null = null
19
26
  let sessionBranch = ""
27
+ let isAgentMode = false
28
+
29
+ /**
30
+ * Detect if we're running in agent/ephemeral mode where session branching
31
+ * should be completely skipped. This happens when:
32
+ * - JFL_AGENT_MODE=1 env var is set (PP agent runs)
33
+ * - PI_PRINT_MODE=1 (set by Pi core in --print mode)
34
+ * - Process was spawned by PP (JFL_PP_SPAWNED=1)
35
+ */
36
+ function detectAgentMode(): boolean {
37
+ return (
38
+ process.env.JFL_AGENT_MODE === "1" ||
39
+ process.env.JFL_PP_SPAWNED === "1" ||
40
+ process.env.PI_PRINT_MODE === "1" ||
41
+ process.argv.includes("--print") ||
42
+ process.argv.includes("-p")
43
+ )
44
+ }
45
+
46
+ /**
47
+ * Get working branch from .jfl/config.json or default to "main"
48
+ */
49
+ function getWorkingBranch(root: string): string {
50
+ const configPath = join(root, ".jfl", "config.json")
51
+ if (existsSync(configPath)) {
52
+ try {
53
+ const cfg = JSON.parse(readFileSync(configPath, "utf-8"))
54
+ if (cfg.working_branch) return cfg.working_branch
55
+ } catch {}
56
+ }
57
+ return "main"
58
+ }
59
+
60
+ /**
61
+ * Merge a stale session branch to the working branch.
62
+ * Called during onInit when we find ourselves on an old session branch,
63
+ * to prevent data loss from abandoned branches.
64
+ *
65
+ * Returns true if merge succeeded (or branch had no unique work).
66
+ */
67
+ function mergeStaleSessionBranch(
68
+ root: string,
69
+ staleBranch: string,
70
+ workingBranch: string,
71
+ ctx: { log: (msg: string, level?: "debug" | "info" | "warn" | "error") => void; ui: { notify: (msg: string, opts?: any) => void } },
72
+ ): boolean {
73
+ try {
74
+ // Check if the stale branch has any commits not in working branch
75
+ const uniqueCommits = execSync(
76
+ `git log --oneline "${workingBranch}".."${staleBranch}" 2>/dev/null | wc -l`,
77
+ { cwd: root, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
78
+ ).trim()
79
+
80
+ if (uniqueCommits === "0") {
81
+ // No unique work — safe to just switch away
82
+ ctx.log(`Stale branch ${staleBranch} has no unique commits, switching to ${workingBranch}`, "debug")
83
+ return true
84
+ }
85
+
86
+ ctx.log(`Stale branch ${staleBranch} has ${uniqueCommits} unique commits — merging to ${workingBranch}`, "info")
87
+ ctx.ui.notify(`⚠ Recovering ${uniqueCommits} commits from stale session ${staleBranch}`, { level: "warn" })
88
+
89
+ // Commit any dirty state first
90
+ try {
91
+ const status = execSync("git status --porcelain", { cwd: root, encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim()
92
+ if (status) {
93
+ execSync("git add -A && git commit -m 'auto: save before stale branch merge' --no-verify", {
94
+ cwd: root, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
95
+ })
96
+ }
97
+ } catch {}
98
+
99
+ // Switch to working branch
100
+ execSync(`git checkout "${workingBranch}"`, { cwd: root, timeout: 10000, stdio: ["pipe", "pipe", "pipe"] })
101
+
102
+ // Merge stale branch with auto-resolve for .jfl conflicts
103
+ try {
104
+ execSync(`git merge "${staleBranch}" --no-edit -X ours`, { cwd: root, timeout: 15000, stdio: ["pipe", "pipe", "pipe"] })
105
+ ctx.ui.notify(`✓ Recovered stale session ${staleBranch} → ${workingBranch}`, { level: "info" })
106
+
107
+ // Clean up the stale branch
108
+ try {
109
+ execSync(`git branch -d "${staleBranch}"`, { cwd: root, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] })
110
+ } catch {}
111
+
112
+ return true
113
+ } catch (err) {
114
+ const msg = err instanceof Error ? err.message : String(err)
115
+ if (msg.includes("CONFLICT") || msg.includes("conflict")) {
116
+ // Abort merge, stay on working branch, leave stale branch for manual resolution
117
+ try { execSync("git merge --abort", { cwd: root, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }) } catch {}
118
+ ctx.ui.notify(`⚠ Merge conflict recovering ${staleBranch} — branch preserved for manual merge`, { level: "warn" })
119
+ return true // Still switch to working branch, just don't delete the old one
120
+ }
121
+ throw err
122
+ }
123
+ } catch (err) {
124
+ ctx.log(`Failed to merge stale branch ${staleBranch}: ${err}`, "warn")
125
+ return false
126
+ }
127
+ }
20
128
 
21
129
  /**
22
130
  * Create a session branch locally without the Hub.
23
131
  * Parity with scripts/session/session-init.sh:
24
- * 1. Commit or stash dirty state
25
- * 2. If on a stale session branch, try to switch to working branch
132
+ * 1. If on a stale session branch, MERGE it first (prevent data loss!)
133
+ * 2. Commit or stash remaining dirty state
26
134
  * 3. Create new session branch from clean base
27
135
  * 4. Pop stash if needed
28
136
  */
@@ -40,23 +148,23 @@ function createSessionBranchLocally(root: string, ctx: { log: (msg: string, leve
40
148
  const randomId = Math.random().toString(16).slice(2, 8)
41
149
  const branchName = `session-${user}-${dateStr}-${timeStr}-${randomId}`
42
150
 
43
- // Get working branch from config
44
- let workingBranch = "main"
45
- const configPath = join(root, ".jfl", "config.json")
46
- if (existsSync(configPath)) {
47
- try {
48
- const cfg = JSON.parse(readFileSync(configPath, "utf-8"))
49
- if (cfg.working_branch) workingBranch = cfg.working_branch
50
- } catch {}
151
+ const workingBranch = getWorkingBranch(root)
152
+
153
+ // Step 1: If on a stale session branch, MERGE it to working branch first
154
+ const currentBranch = getCurrentBranch(root)
155
+ if (currentBranch.startsWith("session-")) {
156
+ ctx.log(`Found stale session branch ${currentBranch} — merging before creating new session`, "info")
157
+ mergeStaleSessionBranch(root, currentBranch, workingBranch, ctx)
158
+ // After merge, we should be on workingBranch
51
159
  }
52
160
 
53
- // Step 1: Commit or stash dirty state so checkout doesn't fail
161
+ // Step 2: Commit or stash dirty state so checkout doesn't fail
54
162
  let stashed = false
55
163
  try {
56
164
  const status = execSync("git status --porcelain", { cwd: root, timeout: 5000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim()
57
165
  if (status) {
58
166
  try {
59
- execSync("git add -A && git commit -m 'auto: session-init save before branch switch' --no-verify", { cwd: root, timeout: 10000, stdio: ["pipe", "pipe", "pipe"] })
167
+ execSync("git add -A && git commit -m 'auto: session-init save' --no-verify", { cwd: root, timeout: 10000, stdio: ["pipe", "pipe", "pipe"] })
60
168
  } catch {
61
169
  // Commit failed — stash instead
62
170
  try {
@@ -67,18 +175,17 @@ function createSessionBranchLocally(root: string, ctx: { log: (msg: string, leve
67
175
  }
68
176
  } catch {}
69
177
 
70
- // Step 2: If on a stale session branch, try to switch to working branch
71
- const currentBranch = getCurrentBranch(root)
72
- if (currentBranch.startsWith("session-")) {
178
+ // Step 3: Ensure we're on working branch before creating session branch
179
+ const nowBranch = getCurrentBranch(root)
180
+ if (nowBranch !== workingBranch) {
73
181
  try {
74
182
  execSync(`git checkout "${workingBranch}"`, { cwd: root, timeout: 10000, stdio: ["pipe", "pipe", "pipe"] })
75
- ctx.log(`Switched from stale session branch ${currentBranch} → ${workingBranch}`, "info")
76
183
  } catch {
77
- ctx.log(`Could not switch from ${currentBranch} — branching from it`, "warn")
184
+ ctx.log(`Could not switch to ${workingBranch} — branching from ${nowBranch}`, "warn")
78
185
  }
79
186
  }
80
187
 
81
- // Step 3: Create new session branch
188
+ // Step 4: Create new session branch
82
189
  let finalBranch = branchName
83
190
  try {
84
191
  execSync(`git checkout -b "${branchName}"`, { cwd: root, stdio: ["pipe", "pipe", "pipe"] })
@@ -98,7 +205,7 @@ function createSessionBranchLocally(root: string, ctx: { log: (msg: string, leve
98
205
  }
99
206
  }
100
207
 
101
- // Step 4: Pop stash if we stashed
208
+ // Step 5: Pop stash if we stashed
102
209
  if (stashed) {
103
210
  try {
104
211
  execSync("git stash pop", { cwd: root, timeout: 10000, stdio: ["pipe", "pipe", "pipe"] })
@@ -130,6 +237,16 @@ function findScript(root: string, scriptName: string): string | null {
130
237
  export async function setupSession(ctx: PiContext, _config: JflConfig): Promise<void> {
131
238
  const root = ctx.session.projectRoot
132
239
 
240
+ // ── Agent mode detection ─────────────────────────────────────────────
241
+ // PP agents work in isolated /tmp worktrees. They must NOT create session
242
+ // branches or do any git operations in the main repo.
243
+ isAgentMode = detectAgentMode()
244
+ if (isAgentMode) {
245
+ sessionBranch = getCurrentBranch(root)
246
+ ctx.log(`Agent mode detected — skipping session branching (on ${sessionBranch})`, "info")
247
+ return
248
+ }
249
+
133
250
  // Call Hub session init — handles sync, doctor, branch creation
134
251
  try {
135
252
  const resp = await fetch(`${hubUrl}/api/session/init`, {
@@ -260,10 +377,48 @@ export async function pivot(ctx: PiContext, summary?: string): Promise<{
260
377
  export async function onShutdown(ctx: PiContext): Promise<void> {
261
378
  const root = ctx.session.projectRoot
262
379
  const branch = getSessionBranch() || getCurrentBranch(root)
380
+ const t0 = Date.now()
263
381
 
264
- ctx.ui.notify("Shutting down session…", { level: "info" })
382
+ ctx.ui.notify("Ending session…", { level: "info" })
265
383
 
266
- // ── 1. Journal check (parity with CC Stop hook) ──────────────────────
384
+ // ── Agent mode: skip all git operations ──────────────────────────────
385
+ if (isAgentMode) {
386
+ ctx.log("Agent mode — skipping session cleanup (no branching was done)", "debug")
387
+ ctx.emit("hook:session-end", { session: ctx.session.id, branch, ts: new Date().toISOString() })
388
+ return
389
+ }
390
+
391
+ // ── PHASE 1: Fire non-blocking tasks immediately ─────────────────────
392
+ // These run in parallel with the sequential git ops below.
393
+ // Using async I/O so they actually progress while git work happens.
394
+
395
+ // 1a. Hub API end (fire-and-forget, 5s cap)
396
+ const hubP = fetch(`${hubUrl}/api/session/end`, {
397
+ method: "POST",
398
+ headers: {
399
+ "Content-Type": "application/json",
400
+ ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
401
+ },
402
+ body: JSON.stringify({ runtime: "pi" }),
403
+ signal: AbortSignal.timeout(5000),
404
+ }).then(() => { ctx.log("Hub session closed", "debug") })
405
+ .catch(() => { ctx.log("Hub session end failed", "debug") })
406
+
407
+ // 1b. Synopsis generation (read-only, safe to run during git ops)
408
+ const synopsisP = execAsync("jfl synopsis 4 2>/dev/null || true", { cwd: root, timeout: 10000 })
409
+ .then(r => r.stdout.trim())
410
+ .catch(() => "")
411
+
412
+ // 1c. Memory indexing (read-only index build, safe to run in parallel)
413
+ const memoryP = execAsync("jfl memory-index 2>/dev/null || true", { cwd: root, timeout: 15000 })
414
+ .then(() => { ctx.log("Memory indexed", "debug") })
415
+ .catch(() => { ctx.log("Memory index skipped", "debug") })
416
+
417
+ // ── PHASE 2: Sequential git ops (critical path) ─────────────────────
418
+ // These MUST be ordered: commit → merge → push
419
+ // Using async exec so parallel tasks can progress between steps.
420
+
421
+ // 2a. Journal check
267
422
  const journalPath = join(root, ".jfl", "journal", `${branch}.jsonl`)
268
423
  const hasJournal = existsSync(journalPath) && readFileSync(journalPath, "utf-8").trim().length > 0
269
424
  if (!hasJournal) {
@@ -274,111 +429,116 @@ export async function onShutdown(ctx: PiContext): Promise<void> {
274
429
  )
275
430
  }
276
431
 
277
- // ── 2. Kill auto-commit daemon ───────────────────────────────────────
432
+ // 2b. Kill auto-commit daemon
278
433
  if (autoCommitProcess) {
279
434
  try { autoCommitProcess.kill() } catch {}
280
435
  autoCommitProcess = null
281
- ctx.ui.notify(" ✓ Auto-commit stopped", { level: "info" })
282
436
  }
283
437
 
284
- // ── 3. Auto-commit any uncommitted changes ───────────────────────────
438
+ // 2c. Auto-commit any uncommitted changes (async — doesn't block event loop)
285
439
  try {
286
- const status = execSync("git status --porcelain", { cwd: root, timeout: 3000, encoding: "utf-8" }).trim()
440
+ const status = (await execAsync("git status --porcelain", { cwd: root, timeout: 3000 })).stdout.trim()
287
441
  if (status) {
288
- execSync("git add -A && git commit -m 'auto: session-end save'", { cwd: root, timeout: 5000, stdio: "pipe" })
289
- ctx.ui.notify(" ✓ Uncommitted changes saved", { level: "info" })
442
+ await execAsync("git add -A && git commit -m 'auto: session-end save' --no-verify", { cwd: root, timeout: 5000 })
443
+ ctx.ui.notify(" ✓ Changes saved", { level: "info" })
290
444
  }
291
445
  } catch {}
292
446
 
293
- // ── 4. Call Hub session end ──────────────────────────────────────────
294
- try {
295
- await fetch(`${hubUrl}/api/session/end`, {
296
- method: "POST",
297
- headers: {
298
- "Content-Type": "application/json",
299
- ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
300
- },
301
- body: JSON.stringify({ runtime: "pi" }),
302
- signal: AbortSignal.timeout(5000),
303
- })
304
- ctx.ui.notify(" ✓ Hub session closed", { level: "info" })
305
- } catch {
306
- ctx.log("Hub session end failed — running local cleanup", "debug")
307
- }
447
+ // 2d. Merge session branch
448
+ let mergeOk = false
449
+ let workingBranch = "main"
308
450
 
309
- // ── 5. Merge session branch back (parity with CC session-cleanup.sh) ─
310
451
  if (branch.startsWith("session-")) {
311
- // Guard: if git is already on a DIFFERENT branch (e.g. a new session started),
312
- // don't merge — the old session branch may already be gone.
313
- const currentGitBranch = getCurrentBranch(root)
314
- if (currentGitBranch !== branch) {
315
- ctx.log(`Skipping merge — git is on ${currentGitBranch}, not ${branch} (new session likely started)`, "debug")
316
- ctx.ui.notify(` ⚠ Skipped merge — already on ${currentGitBranch}`, { level: "info" })
317
- } else try {
318
- // Get working branch from config or default to main
319
- let workingBranch = "main"
320
- const configPath = join(root, ".jfl", "config.json")
321
- if (existsSync(configPath)) {
322
- try {
323
- const config = JSON.parse(readFileSync(configPath, "utf-8"))
324
- if (config.working_branch) workingBranch = config.working_branch
325
- } catch {}
326
- }
452
+ try {
453
+ workingBranch = getWorkingBranch(root)
327
454
 
328
- // Remove stale lock files that block git operations
455
+ // Remove stale lock files
329
456
  const lockFile = join(root, ".git", "index.lock")
330
457
  if (existsSync(lockFile)) {
331
- try { require("fs").unlinkSync(lockFile) } catch {}
458
+ try { unlinkSync(lockFile) } catch {}
332
459
  }
333
460
 
334
- // Commit any dirty tracked files before switching branches
335
- try {
336
- execSync("git add -A && git commit -m 'session: auto-commit before merge' --no-verify", {
337
- cwd: root, timeout: 10000, stdio: "pipe",
338
- })
339
- } catch {} // OK if nothing to commit
461
+ // Ensure we're on our session branch
462
+ const currentGitBranch = getCurrentBranch(root)
463
+ if (currentGitBranch !== branch) {
464
+ try {
465
+ await execAsync(`git checkout "${branch}"`, { cwd: root, timeout: 10000 })
466
+ ctx.log(`Recovered session branch from ${currentGitBranch}`, "info")
467
+ } catch {
468
+ ctx.log(`Cannot checkout ${branch} — attempting merge anyway`, "warn")
469
+ }
470
+ }
340
471
 
341
- // Push session branch first
472
+ // Commit any remaining dirty state
342
473
  try {
343
- execSync(`git push origin ${branch}`, { cwd: root, timeout: 15000, stdio: "pipe" })
344
- ctx.ui.notify(" ✓ Session branch pushed", { level: "info" })
345
- } catch {}
474
+ await execAsync("git add -A && git commit -m 'session: auto-commit before merge' --no-verify", { cwd: root, timeout: 10000 })
475
+ } catch {} // OK if nothing to commit
346
476
 
347
- // Merge session into working branch
348
- execSync(`git checkout ${workingBranch}`, { cwd: root, timeout: 5000, stdio: "pipe" })
349
- execSync(`git merge ${branch} --no-edit`, { cwd: root, timeout: 10000, stdio: "pipe" })
350
- ctx.ui.notify(` ✓ Merged ${branch} → ${workingBranch}`, { level: "info" })
477
+ // Merge session into working branch (skip session branch push — we'll push working branch after merge)
478
+ await execAsync(`git checkout "${workingBranch}"`, { cwd: root, timeout: 5000 })
479
+ await execAsync(`git merge "${branch}" --no-edit`, { cwd: root, timeout: 10000 })
480
+ ctx.ui.notify(` ✓ Merged → ${workingBranch}`, { level: "info" })
481
+ mergeOk = true
351
482
 
352
- // Push merged working branch
483
+ // Delete local session branch immediately (fast, no network)
353
484
  try {
354
- execSync(`git push origin ${workingBranch}`, { cwd: root, timeout: 15000, stdio: "pipe" })
485
+ await execAsync(`git branch -d "${branch}"`, { cwd: root, timeout: 5000 })
355
486
  } catch {}
356
487
 
357
- // Delete session branch (local + remote)
358
- try {
359
- execSync(`git branch -d ${branch}`, { cwd: root, timeout: 5000, stdio: "pipe" })
360
- execSync(`git push origin --delete ${branch}`, { cwd: root, timeout: 10000, stdio: "pipe" })
361
- ctx.ui.notify(" ✓ Session branch cleaned up", { level: "info" })
362
- } catch {}
363
488
  } catch (err) {
364
489
  const msg = err instanceof Error ? err.message : String(err)
365
490
  if (msg.includes("CONFLICT") || msg.includes("conflict")) {
366
- ctx.ui.notify(` ⚠ Merge conflict — session branch ${branch} preserved`, { level: "warn" })
367
- // Switch back to session branch so user can resolve
368
- try { execSync(`git checkout ${branch}`, { cwd: root, timeout: 5000, stdio: "pipe" }) } catch {}
491
+ ctx.ui.notify(` ⚠ Merge conflict — branch ${branch} preserved`, { level: "warn" })
492
+ try { await execAsync(`git checkout "${branch}"`, { cwd: root, timeout: 5000 }) } catch {}
369
493
  } else {
370
- ctx.ui.notify(` ⚠ Branch merge failed: ${msg.slice(0, 80)}`, { level: "warn" })
494
+ ctx.ui.notify(` ⚠ Merge failed: ${msg.slice(0, 100)}`, { level: "warn" })
495
+ try { await execAsync(`git checkout "${branch}"`, { cwd: root, timeout: 5000 }) } catch {}
371
496
  }
372
497
  }
373
498
  }
374
499
 
375
- // ── 6. Fallback cleanup script ───────────────────────────────────────
376
- const cleanupScript = findScript(root, "session-cleanup.sh")
377
- if (cleanupScript && !branch.startsWith("session-")) {
378
- // Only run cleanup script if we didn't already handle merge above
500
+ // ── PHASE 3: Parallel push + cleanup + collect ───────────────────────
501
+ // Push and remote cleanup are network I/O — run them in parallel with
502
+ // synopsis/memory collection. Single git push command does both push +
503
+ // remote branch delete to avoid lock contention.
504
+
505
+ const pushP = (async () => {
506
+ if (!mergeOk) return
379
507
  try {
380
- execSync(`bash "${cleanupScript}"`, { cwd: root, timeout: 10000, stdio: "pipe" })
381
- } catch {}
508
+ // Single push: update working branch AND delete remote session branch
509
+ // This avoids two separate network round-trips and git lock contention
510
+ await execAsync(
511
+ `git push origin "${workingBranch}" ":${branch}" 2>/dev/null || git push origin "${workingBranch}" 2>/dev/null || true`,
512
+ { cwd: root, timeout: 20000 }
513
+ )
514
+ ctx.ui.notify(" ✓ Pushed & cleaned up remote", { level: "info" })
515
+ } catch {
516
+ ctx.log("Push failed — run manually: git push", "warn")
517
+ }
518
+ })()
519
+
520
+ // ── PHASE 4: Await all parallel work ─────────────────────────────────
521
+ const [, synopsisResult] = await Promise.allSettled([hubP, synopsisP, memoryP, pushP])
522
+
523
+ // Show synopsis if we got one
524
+ if (synopsisResult.status === "fulfilled" && synopsisResult.value) {
525
+ const lines = (synopsisResult.value as string).split("\n")
526
+ // Show condensed version — first 20 lines max
527
+ const condensed = lines.slice(0, 20).join("\n")
528
+ if (condensed.trim()) {
529
+ ctx.ui.notify(`\n${condensed}`, { level: "info" })
530
+ }
531
+ }
532
+
533
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1)
534
+ ctx.ui.notify(`✓ Session ended (${elapsed}s)`, { level: "info" })
535
+
536
+ // ── Fallback cleanup script for non-session branches ─────────────────
537
+ if (!branch.startsWith("session-")) {
538
+ const cleanupScript = findScript(root, "session-cleanup.sh")
539
+ if (cleanupScript) {
540
+ try { await execAsync(`bash "${cleanupScript}"`, { cwd: root, timeout: 10000 }) } catch {}
541
+ }
382
542
  }
383
543
 
384
544
  ctx.emit("hook:session-end", {
@@ -29,6 +29,14 @@ JFL's session architecture guarantees work preservation through multiple safety
29
29
 
30
30
  **Note for Pi sessions:** Hook enforcement is handled by the `session_shutdown` extension (`extensions/session.ts`), not `.claude/settings.json` Stop hooks. The cleanup script and journal checks run via the Pi extension lifecycle.
31
31
 
32
+ **Parallel architecture (session.ts):** The `onShutdown` hook runs 4 phases concurrently:
33
+ - **Phase 1 (fire immediately):** Hub API end, synopsis generation, memory indexing — all start in parallel before git ops
34
+ - **Phase 2 (sequential):** Commit → merge (must be ordered, but uses async exec so Phase 1 tasks progress between steps)
35
+ - **Phase 3 (after merge):** Push + remote branch delete in single command, runs in parallel with Phase 1 collection
36
+ - **Phase 4 (collect):** Await all parallel tasks, show synopsis, report timing
37
+
38
+ This means the user sees "Session ended (Xs)" where X is roughly max(merge_time, push_time, synopsis_time) instead of the sum of all operations.
39
+
32
40
  When you invoke `/end`, the user wants to:
33
41
  - **Know what happened** - What commits merged? What changed?
34
42
  - **Trust their work is saved** - No uncertainty about state
@@ -127,15 +127,28 @@ fi
127
127
  echo "Attempting to merge $BRANCH to $WORKING_BRANCH..."
128
128
  cd "$MAIN_REPO"
129
129
 
130
+ # Commit any dirty .jfl/ files that background processes (PP, flow engine) may have written.
131
+ # Without this, git checkout refuses to switch branches when these files differ.
132
+ if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
133
+ echo "Committing background-modified files before checkout..."
134
+ git add -A .jfl/ 2>/dev/null || true
135
+ git diff --cached --quiet 2>/dev/null || git commit -m "session: auto-commit before merge" 2>/dev/null || true
136
+ fi
137
+
130
138
  # Checkout working branch in the main repo
131
139
  if ! git checkout "$WORKING_BRANCH" 2>/dev/null; then
132
- echo "⚠ Could not checkout $WORKING_BRANCH, skipping merge"
133
- echo " Session branch $BRANCH preserved for manual merge"
134
- # Notify jfl-services that session ended
135
- if command -v curl >/dev/null 2>&1; then
136
- curl -s -X DELETE "http://localhost:3401/sessions/$BRANCH" >/dev/null 2>&1 || true
140
+ # Last resort: stash anything remaining and retry
141
+ echo "Checkout blocked by dirty files, stashing..."
142
+ git stash push -m "session-cleanup: auto-stash before merge" 2>/dev/null || true
143
+ if ! git checkout "$WORKING_BRANCH" 2>/dev/null; then
144
+ echo "⚠ Could not checkout $WORKING_BRANCH, skipping merge"
145
+ echo " Session branch $BRANCH preserved for manual merge"
146
+ # Notify jfl-services that session ended
147
+ if command -v curl >/dev/null 2>&1; then
148
+ curl -s -X DELETE "http://localhost:3401/sessions/$BRANCH" >/dev/null 2>&1 || true
149
+ fi
150
+ exit 0
137
151
  fi
138
- exit 0
139
152
  fi
140
153
 
141
154
  # Attempt merge with auto-resolve for .jfl/ conflicts
@@ -513,6 +513,9 @@ jobs:
513
513
  PR_NUMBER=${{ github.event.pull_request.number }}
514
514
  IMPROVED="${{ steps.delta.outputs.improved }}"
515
515
 
516
+ DELTA="${{ steps.delta.outputs.delta }}"
517
+ REGRESSION=$(node -e "console.log(parseFloat('$DELTA') < 0)")
518
+
516
519
  if [ "$IMPROVED" = "true" ]; then
517
520
  # Check if AI review requested changes
518
521
  REVIEW_STATE=$(gh pr view $PR_NUMBER --json reviews --jq '[.reviews[] | select(.author.login == "github-actions")] | last | .state // "NONE"' 2>/dev/null || echo "NONE")
@@ -525,10 +528,14 @@ jobs:
525
528
  gh pr merge $PR_NUMBER --merge --delete-branch \
526
529
  --body "Auto-merged by JFL eval: test_pass_rate improved by ${{ steps.delta.outputs.delta }} (${{ steps.baseline.outputs.score }} → ${{ steps.pr_eval.outputs.score }})"
527
530
  fi
528
- else
531
+ elif [ "$REGRESSION" = "true" ]; then
529
532
  echo "Eval regression — requesting changes on PR #$PR_NUMBER"
530
533
  gh pr review $PR_NUMBER --request-changes \
531
534
  --body "JFL eval regression: test_pass_rate dropped by ${{ steps.delta.outputs.delta }} (${{ steps.baseline.outputs.score }} → ${{ steps.pr_eval.outputs.score }}). Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
535
+ else
536
+ echo "Eval neutral (no improvement, no regression) — approving PR #$PR_NUMBER"
537
+ gh pr review $PR_NUMBER --approve \
538
+ --body "JFL eval: no regression (delta=${{ steps.delta.outputs.delta }}, ${{ steps.baseline.outputs.score }} → ${{ steps.pr_eval.outputs.score }}). Manual merge OK."
532
539
  fi
533
540
 
534
541
  - name: Eval Summary
@@ -48,8 +48,10 @@ if [ -f ".auto-merge.pid" ]; then
48
48
  rm -f ".auto-merge.pid"
49
49
  fi
50
50
 
51
- # Context Hub is a persistent daemon do NOT kill it on session end.
52
- # It serves multiple sessions and runtimes (Claude Code, Pi, etc).
51
+ # Context Hub is a DAEMONpersists across sessions. Do NOT kill it here.
52
+ # Killing it before Stop hooks fire causes ECONNREFUSED on all HTTP hooks.
53
+ # The hub has 5-layer resilience (launchd, MCP auto-recovery, SessionStart ensure,
54
+ # self-healing on crash, startup grace period). Only stop via: jfl context-hub stop
53
55
 
54
56
  # Get current session info
55
57
  BRANCH=$(git branch --show-current 2>/dev/null || echo "")
@@ -125,15 +127,28 @@ fi
125
127
  echo "Attempting to merge $BRANCH to $WORKING_BRANCH..."
126
128
  cd "$MAIN_REPO"
127
129
 
130
+ # Commit any dirty .jfl/ files that background processes (PP, flow engine) may have written.
131
+ # Without this, git checkout refuses to switch branches when these files differ.
132
+ if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
133
+ echo "Committing background-modified files before checkout..."
134
+ git add -A .jfl/ 2>/dev/null || true
135
+ git diff --cached --quiet 2>/dev/null || git commit -m "session: auto-commit before merge" 2>/dev/null || true
136
+ fi
137
+
128
138
  # Checkout working branch in the main repo
129
139
  if ! git checkout "$WORKING_BRANCH" 2>/dev/null; then
130
- echo "⚠ Could not checkout $WORKING_BRANCH, skipping merge"
131
- echo " Session branch $BRANCH preserved for manual merge"
132
- # Notify jfl-services that session ended
133
- if command -v curl >/dev/null 2>&1; then
134
- curl -s -X DELETE "http://localhost:3401/sessions/$BRANCH" >/dev/null 2>&1 || true
140
+ # Last resort: stash anything remaining and retry
141
+ echo "Checkout blocked by dirty files, stashing..."
142
+ git stash push -m "session-cleanup: auto-stash before merge" 2>/dev/null || true
143
+ if ! git checkout "$WORKING_BRANCH" 2>/dev/null; then
144
+ echo "⚠ Could not checkout $WORKING_BRANCH, skipping merge"
145
+ echo " Session branch $BRANCH preserved for manual merge"
146
+ # Notify jfl-services that session ended
147
+ if command -v curl >/dev/null 2>&1; then
148
+ curl -s -X DELETE "http://localhost:3401/sessions/$BRANCH" >/dev/null 2>&1 || true
149
+ fi
150
+ exit 0
135
151
  fi
136
- exit 0
137
152
  fi
138
153
 
139
154
  # Attempt merge with auto-resolve for .jfl/ conflicts