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.
- package/dist/commands/init-from-service.d.ts.map +1 -1
- package/dist/commands/init-from-service.js +2 -2
- package/dist/commands/init-from-service.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +88 -23
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/peter.d.ts.map +1 -1
- package/dist/commands/peter.js +112 -35
- package/dist/commands/peter.js.map +1 -1
- package/dist/commands/repair.d.ts.map +1 -1
- package/dist/commands/repair.js +13 -11
- package/dist/commands/repair.js.map +1 -1
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +7 -40
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/start.js +3 -3
- package/dist/commands/start.js.map +1 -1
- package/dist/lib/agent-config.d.ts +1 -0
- package/dist/lib/agent-config.d.ts.map +1 -1
- package/dist/lib/agent-config.js.map +1 -1
- package/dist/lib/agent-guards.d.ts +67 -0
- package/dist/lib/agent-guards.d.ts.map +1 -0
- package/dist/lib/agent-guards.js +229 -0
- package/dist/lib/agent-guards.js.map +1 -0
- package/dist/lib/agent-runtime-api.d.ts +32 -0
- package/dist/lib/agent-runtime-api.d.ts.map +1 -0
- package/dist/lib/agent-runtime-api.js +270 -0
- package/dist/lib/agent-runtime-api.js.map +1 -0
- package/dist/lib/agent-session.d.ts.map +1 -1
- package/dist/lib/agent-session.js +255 -25
- package/dist/lib/agent-session.js.map +1 -1
- package/dist/lib/gtm-generator.js +3 -1
- package/dist/lib/gtm-generator.js.map +1 -1
- package/dist/lib/memory-search.d.ts.map +1 -1
- package/dist/lib/memory-search.js +0 -8
- package/dist/lib/memory-search.js.map +1 -1
- package/dist/utils/jfl-paths.d.ts +9 -0
- package/dist/utils/jfl-paths.d.ts.map +1 -1
- package/dist/utils/jfl-paths.js +13 -0
- package/dist/utils/jfl-paths.js.map +1 -1
- package/package.json +1 -1
- package/packages/pi/dist/index.d.ts.map +1 -1
- package/packages/pi/dist/index.js +19 -1
- package/packages/pi/dist/index.js.map +1 -1
- package/packages/pi/dist/session.d.ts +5 -1
- package/packages/pi/dist/session.d.ts.map +1 -1
- package/packages/pi/dist/session.js +247 -116
- package/packages/pi/dist/session.js.map +1 -1
- package/packages/pi/extensions/index.ts +24 -1
- package/packages/pi/extensions/session.ts +256 -96
- package/packages/pi/skills/end/SKILL.md +8 -0
- package/scripts/session/session-cleanup.sh +19 -6
- package/template/.github/workflows/jfl-eval.yml +8 -1
- 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
|
|
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 {
|
|
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.
|
|
25
|
-
* 2.
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
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
|
|
184
|
+
ctx.log(`Could not switch to ${workingBranch} — branching from ${nowBranch}`, "warn")
|
|
78
185
|
}
|
|
79
186
|
}
|
|
80
187
|
|
|
81
|
-
// Step
|
|
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
|
|
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("
|
|
382
|
+
ctx.ui.notify("Ending session…", { level: "info" })
|
|
265
383
|
|
|
266
|
-
// ──
|
|
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
|
-
//
|
|
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
|
-
//
|
|
438
|
+
// 2c. Auto-commit any uncommitted changes (async — doesn't block event loop)
|
|
285
439
|
try {
|
|
286
|
-
const status =
|
|
440
|
+
const status = (await execAsync("git status --porcelain", { cwd: root, timeout: 3000 })).stdout.trim()
|
|
287
441
|
if (status) {
|
|
288
|
-
|
|
289
|
-
ctx.ui.notify(" ✓
|
|
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
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
|
455
|
+
// Remove stale lock files
|
|
329
456
|
const lockFile = join(root, ".git", "index.lock")
|
|
330
457
|
if (existsSync(lockFile)) {
|
|
331
|
-
try {
|
|
458
|
+
try { unlinkSync(lockFile) } catch {}
|
|
332
459
|
}
|
|
333
460
|
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
//
|
|
472
|
+
// Commit any remaining dirty state
|
|
342
473
|
try {
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
ctx.ui.notify(` ✓ Merged
|
|
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
|
-
//
|
|
483
|
+
// Delete local session branch immediately (fast, no network)
|
|
353
484
|
try {
|
|
354
|
-
|
|
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 —
|
|
367
|
-
|
|
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(` ⚠
|
|
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
|
-
// ──
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
133
|
-
echo "
|
|
134
|
-
|
|
135
|
-
if
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
#
|
|
51
|
+
# Context Hub is a DAEMON — persists 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
|
-
|
|
131
|
-
echo "
|
|
132
|
-
|
|
133
|
-
if
|
|
134
|
-
|
|
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
|