@ucsandman/legcli 0.7.0
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/CHANGELOG.md +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- package/src/worktree.mjs +207 -0
package/src/env.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Child-process variable sanitization shared by every adapter. Lives in its
|
|
2
|
+
// own module (re-exported by src/runner.mjs) so adapters can import it without
|
|
3
|
+
// a top-level-await import cycle through the runner.
|
|
4
|
+
//
|
|
5
|
+
// Subscription auth only: every provider API key and base-URL override goes
|
|
6
|
+
// (Anthropic, OpenAI, and the Google/Gemini set agy honours), and so do the session markers
|
|
7
|
+
// a Claude Code parent would leak into the child (a leaked session key
|
|
8
|
+
// overrides the machine login and kills the run with "Invalid API key"). The
|
|
9
|
+
// print ceiling must be 0 for a detached claude -p.
|
|
10
|
+
export function sanitizeEnv(env, { interactive = false } = {}) {
|
|
11
|
+
const out = { ...env }
|
|
12
|
+
for (const key of Object.keys(out)) {
|
|
13
|
+
if (/^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ANTHROPIC_BASE_URL|ANTHROPIC_CUSTOM_HEADERS|OPENAI_API_KEY|OPENAI_BASE_URL|OPENAI_API_BASE|GEMINI_API_KEY|GOOGLE_API_KEY|GOOGLE_GEMINI_BASE_URL|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_ENTERPRISE|GOOGLE_CLOUD_PROJECT|GOOGLE_CLOUD_LOCATION|GOOGLE_APPLICATION_CREDENTIALS|CLAUDECODE|CLAUDE_CODE_\w+|CLAUDE_EFFORT|CLAUDE_PLUGIN_DATA)$/.test(key)) {
|
|
14
|
+
delete out[key]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (!interactive) out.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS = '0'
|
|
18
|
+
return out
|
|
19
|
+
}
|
package/src/fsx.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// fsx — atomic JSON writes that survive Windows. Readers (board, orchestrator,
|
|
2
|
+
// tests) poll card.json and run.json; write-then-rename keeps them from seeing
|
|
3
|
+
// a torn file, but on Windows a rename over a file another process has open
|
|
4
|
+
// fails with EPERM/EBUSY for a moment. Retry briefly, then fall back to a
|
|
5
|
+
// direct write rather than lose the record (the supervisor's final run.json
|
|
6
|
+
// once went missing this way and the orchestrator read a stale 'running').
|
|
7
|
+
import { writeFileSync, renameSync, unlinkSync, existsSync, realpathSync, openSync, closeSync, statSync } from 'node:fs'
|
|
8
|
+
import { resolve, dirname, basename, join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
// Canonical form for comparing paths: symlinks resolved and, on Windows, 8.3
|
|
11
|
+
// short names expanded (a GitHub runner's temp dir is C:\Users\RUNNER~1\...
|
|
12
|
+
// while git reports C:\Users\runneradmin\...). A path that does not exist
|
|
13
|
+
// yet is canonicalised through its deepest existing ancestor.
|
|
14
|
+
export function canonPath(p) {
|
|
15
|
+
const out = realPath(p)
|
|
16
|
+
return process.platform === 'win32' ? out.toLowerCase() : out
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The real, long-form path (case preserved): what Baton stores and hands to
|
|
20
|
+
// git, so a short or symlinked input never leaks into card.json or worktrees.
|
|
21
|
+
export function realPath(p) {
|
|
22
|
+
let base = resolve(p)
|
|
23
|
+
const rest = []
|
|
24
|
+
while (!existsSync(base)) {
|
|
25
|
+
const parent = dirname(base)
|
|
26
|
+
if (parent === base) break
|
|
27
|
+
rest.unshift(basename(base))
|
|
28
|
+
base = parent
|
|
29
|
+
}
|
|
30
|
+
let out = base
|
|
31
|
+
try { out = realpathSync.native(base) } catch {}
|
|
32
|
+
return rest.length ? join(out, ...rest) : out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sleepSync = (ms) => { const t = Date.now() + ms; while (Date.now() < t) { /* spin */ } }
|
|
36
|
+
|
|
37
|
+
// Cross-process advisory lock around a read-modify-write of a shared JSON file.
|
|
38
|
+
// `openSync(..., 'wx')` is atomic-create, so only one process (a hook, a tap, a
|
|
39
|
+
// poller) holds it at a time. A lock older than staleMs (a crashed holder) is
|
|
40
|
+
// stolen. If it cannot be acquired within the budget, fn runs anyway rather
|
|
41
|
+
// than hang the caller (a Claude Code hook must never block the user's turn).
|
|
42
|
+
export function withFileLock(lockPath, fn, { retries = 60, waitMs = 20, staleMs = 5000 } = {}) {
|
|
43
|
+
let fd = null
|
|
44
|
+
for (let i = 0; i < retries; i++) {
|
|
45
|
+
try { fd = openSync(lockPath, 'wx'); break } catch (err) {
|
|
46
|
+
// EEXIST: held. EPERM/EACCES/EBUSY: Windows, the holder's unlink is
|
|
47
|
+
// still pending and the path is briefly untouchable; that is "held" too,
|
|
48
|
+
// not a reason to run unlocked.
|
|
49
|
+
if (!['EEXIST', 'EPERM', 'EACCES', 'EBUSY'].includes(err.code)) break
|
|
50
|
+
try { if (Date.now() - statSync(lockPath).mtimeMs > staleMs) { unlinkSync(lockPath); continue } } catch {}
|
|
51
|
+
sleepSync(waitMs)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try { return fn() } finally { if (fd !== null) { try { closeSync(fd) } catch {} try { unlinkSync(lockPath) } catch {} } }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function writeJsonAtomic(file, obj) {
|
|
58
|
+
const text = JSON.stringify(obj, null, 2) + '\n'
|
|
59
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
60
|
+
writeFileSync(tmp, text)
|
|
61
|
+
for (let i = 0; i < 20; i++) {
|
|
62
|
+
try {
|
|
63
|
+
renameSync(tmp, file)
|
|
64
|
+
return
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!['EPERM', 'EBUSY', 'EACCES', 'EEXIST'].includes(err.code)) { try { unlinkSync(tmp) } catch {} throw err }
|
|
67
|
+
sleepSync(25)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
try { unlinkSync(tmp) } catch {}
|
|
71
|
+
writeFileSync(file, text)
|
|
72
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ported 2026-09-10 from private ucsandman team tooling; see NOTICE and docs/REUSE.md.
|
|
3
|
+
// git-snapshot — read-only git state snapshot. Prints ONE compact JSON object to stdout. Never mutates the repo.
|
|
4
|
+
// See PROTOCOL.md § Git workflow (coding tasks).
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import { existsSync, statSync } from 'node:fs'
|
|
7
|
+
import { resolve } from 'node:path'
|
|
8
|
+
|
|
9
|
+
function die(code, msg) {
|
|
10
|
+
process.stderr.write(msg + '\n')
|
|
11
|
+
process.exit(code)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const args = {}
|
|
16
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
17
|
+
if (!argv[i].startsWith('--') || argv[i + 1] === undefined) {
|
|
18
|
+
die(2, `bad argument pair near "${argv[i]}"`)
|
|
19
|
+
}
|
|
20
|
+
args[argv[i].slice(2)] = argv[i + 1]
|
|
21
|
+
}
|
|
22
|
+
return args
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const args = parseArgs(process.argv.slice(2))
|
|
26
|
+
|
|
27
|
+
const repoArg = args['repo']
|
|
28
|
+
if (!repoArg) die(2, 'missing --repo <path>')
|
|
29
|
+
const repo = resolve(repoArg)
|
|
30
|
+
if (!existsSync(repo) || !statSync(repo).isDirectory()) {
|
|
31
|
+
die(2, `not a directory: ${repo}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let maxFiles = 20
|
|
35
|
+
if (args['max-files'] !== undefined) {
|
|
36
|
+
maxFiles = parseInt(args['max-files'], 10)
|
|
37
|
+
if (!Number.isInteger(maxFiles) || maxFiles < 1) {
|
|
38
|
+
die(2, `invalid --max-files "${args['max-files']}"`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function git(gitArgs) {
|
|
43
|
+
// MSYS_NO_PATHCONV: Git Bash would rewrite a leading-slash argument into a
|
|
44
|
+
// Windows path (LESSONS 07-09); harmless elsewhere.
|
|
45
|
+
return execFileSync('git', gitArgs, { cwd: repo, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
git(['rev-parse', '--is-inside-work-tree'])
|
|
50
|
+
} catch {
|
|
51
|
+
die(2, `not a git repo: ${repo}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let branch
|
|
55
|
+
try {
|
|
56
|
+
branch = git(['symbolic-ref', '--short', '-q', 'HEAD']).trim()
|
|
57
|
+
} catch {
|
|
58
|
+
branch = 'DETACHED'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let head, head_subject
|
|
62
|
+
try {
|
|
63
|
+
head = git(['rev-parse', '--short', 'HEAD']).trim()
|
|
64
|
+
head_subject = git(['log', '-1', '--format=%s']).trim()
|
|
65
|
+
} catch {
|
|
66
|
+
die(2, `repo has no commits: ${repo}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const statusLines = git(['status', '--porcelain']).split(/\r?\n/).filter(Boolean)
|
|
70
|
+
let staged = 0
|
|
71
|
+
let modified = 0
|
|
72
|
+
let untracked = 0
|
|
73
|
+
for (const line of statusLines) {
|
|
74
|
+
if (line.startsWith('??')) {
|
|
75
|
+
untracked++
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (line[0] !== ' ') staged++
|
|
79
|
+
if (line[1] !== ' ') modified++
|
|
80
|
+
}
|
|
81
|
+
const dirty = {
|
|
82
|
+
staged,
|
|
83
|
+
modified,
|
|
84
|
+
untracked,
|
|
85
|
+
files: statusLines.slice(0, maxFiles),
|
|
86
|
+
truncated: statusLines.length > maxFiles,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let submodules_dirty = []
|
|
90
|
+
try {
|
|
91
|
+
const subLines = git(['submodule', 'status']).split(/\r?\n/).filter(Boolean)
|
|
92
|
+
submodules_dirty = subLines
|
|
93
|
+
.filter((l) => l[0] === '+' || l[0] === 'U')
|
|
94
|
+
.map((l) => l.slice(1).trim().split(/\s+/)[1])
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
} catch {
|
|
97
|
+
submodules_dirty = []
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let recommendation
|
|
101
|
+
if (dirty.staged === 0 && dirty.modified === 0 && dirty.untracked === 0) {
|
|
102
|
+
recommendation = 'branch'
|
|
103
|
+
} else {
|
|
104
|
+
recommendation = 'worktree'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const snapshot = {
|
|
108
|
+
ts: new Date().toISOString(),
|
|
109
|
+
repo,
|
|
110
|
+
branch,
|
|
111
|
+
head,
|
|
112
|
+
head_subject,
|
|
113
|
+
dirty,
|
|
114
|
+
submodules_dirty,
|
|
115
|
+
recommendation,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (args['diff-since']) {
|
|
119
|
+
const since = args['diff-since']
|
|
120
|
+
let logLines
|
|
121
|
+
let numstatLines
|
|
122
|
+
try {
|
|
123
|
+
logLines = git(['log', '--oneline', `${since}..HEAD`]).split(/\r?\n/).filter(Boolean)
|
|
124
|
+
numstatLines = git(['diff', '--numstat', `${since}..HEAD`]).split(/\r?\n/).filter(Boolean)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
die(2, `invalid --diff-since ref "${since}": ${err.message}`)
|
|
127
|
+
}
|
|
128
|
+
const commits = logLines.map((l) => {
|
|
129
|
+
const sp = l.indexOf(' ')
|
|
130
|
+
return sp === -1 ? { hash: l, subject: '' } : { hash: l.slice(0, sp), subject: l.slice(sp + 1) }
|
|
131
|
+
})
|
|
132
|
+
let insertions = 0
|
|
133
|
+
let deletions = 0
|
|
134
|
+
const files = []
|
|
135
|
+
for (const line of numstatLines) {
|
|
136
|
+
const [ins, del, path] = line.split('\t')
|
|
137
|
+
if (ins !== '-') insertions += parseInt(ins, 10)
|
|
138
|
+
if (del !== '-') deletions += parseInt(del, 10)
|
|
139
|
+
files.push(path)
|
|
140
|
+
}
|
|
141
|
+
snapshot.diff = {
|
|
142
|
+
since,
|
|
143
|
+
commits,
|
|
144
|
+
changed_files: numstatLines.length,
|
|
145
|
+
insertions,
|
|
146
|
+
deletions,
|
|
147
|
+
files: files.slice(0, maxFiles),
|
|
148
|
+
truncated: files.length > maxFiles,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
process.stdout.write(JSON.stringify(snapshot) + '\n')
|
package/src/handoff.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// handoff — the context-handoff-bundle seam. Baton never re-implements the
|
|
2
|
+
// bundle format: it writes a structured notes file, calls the CLI as an argv
|
|
3
|
+
// subprocess (`save --repo-local` inside the worktree so the next agent finds
|
|
4
|
+
// the bundle in its cwd), validates the bundle, and later `load`s the resume.
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { scrub } from './runner.mjs'
|
|
9
|
+
|
|
10
|
+
const MIN_VERSION = [0, 4, 0]
|
|
11
|
+
|
|
12
|
+
let resolved = null
|
|
13
|
+
|
|
14
|
+
// `context-handoff-bundle` on PATH (a real exe from pip's Scripts dir on
|
|
15
|
+
// Windows, a script elsewhere), else `python -m context_handoff_bundle`.
|
|
16
|
+
export function resolveChb() {
|
|
17
|
+
if (resolved) return resolved
|
|
18
|
+
const chbEnv = process.env.LEG_CHB_BIN || process.env.BATON_CHB_BIN;
|
|
19
|
+
if (chbEnv) {
|
|
20
|
+
resolved = { bin: chbEnv, prefix: [] }
|
|
21
|
+
return resolved
|
|
22
|
+
}
|
|
23
|
+
const direct = spawnSync('context-handoff-bundle', ['--help'], { windowsHide: true, encoding: 'utf8', timeout: 20000 })
|
|
24
|
+
if (!direct.error && direct.status === 0) {
|
|
25
|
+
resolved = { bin: 'context-handoff-bundle', prefix: [] }
|
|
26
|
+
return resolved
|
|
27
|
+
}
|
|
28
|
+
for (const py of ['python', 'python3', 'py']) {
|
|
29
|
+
const r = spawnSync(py, ['-m', 'context_handoff_bundle', '--help'], { windowsHide: true, encoding: 'utf8', timeout: 20000 })
|
|
30
|
+
if (!r.error && r.status === 0) {
|
|
31
|
+
resolved = { bin: py, prefix: ['-m', 'context_handoff_bundle'] }
|
|
32
|
+
return resolved
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
throw new Error('context-handoff-bundle not found: pip install -U context-handoff-bundle')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function chb(args, { cwd, timeout = 120000 } = {}) {
|
|
39
|
+
const { bin, prefix } = resolveChb()
|
|
40
|
+
const r = spawnSync(bin, [...prefix, ...args], { cwd, windowsHide: true, encoding: 'utf8', timeout, env: process.env })
|
|
41
|
+
if (r.error) throw new Error(`context-handoff-bundle ${args[0]} failed to start: ${r.error.message}`)
|
|
42
|
+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Version from package metadata (works on releases without --version).
|
|
46
|
+
export function chbVersion() {
|
|
47
|
+
const v = chb(['--version'])
|
|
48
|
+
let text = v.status === 0 ? v.stdout.trim().split(/\s+/).pop() : null
|
|
49
|
+
if (!text) {
|
|
50
|
+
const r = spawnSync('python', ['-c', "from importlib.metadata import version; print(version('context-handoff-bundle'))"], { windowsHide: true, encoding: 'utf8', timeout: 20000 })
|
|
51
|
+
text = r.status === 0 ? r.stdout.trim() : null
|
|
52
|
+
}
|
|
53
|
+
return text
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function versionOk(v = chbVersion()) {
|
|
57
|
+
if (!v) return false
|
|
58
|
+
const parts = v.split('.').map((x) => parseInt(x, 10))
|
|
59
|
+
for (let i = 0; i < 3; i++) {
|
|
60
|
+
if ((parts[i] ?? 0) > MIN_VERSION[i]) return true
|
|
61
|
+
if ((parts[i] ?? 0) < MIN_VERSION[i]) return false
|
|
62
|
+
}
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bullets(items) {
|
|
67
|
+
return items.filter(Boolean).map((x) => `- ${String(x).replace(/\r?\n/g, ' ').trim()}`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Notes in the CLI's own section vocabulary (Scope / Findings / Opportunities /
|
|
71
|
+
// Open questions / Evidence anchors) carrying Baton's four parts: Task, Done so
|
|
72
|
+
// far, Diff, Open findings. Anything not under a known heading is dropped by
|
|
73
|
+
// the parser, so every Baton line lives under one of those five.
|
|
74
|
+
export function buildNotes({ card, station, leg, entry, run, progress = '', lastMessage = null, diff = null, diffStat = '', changedFiles = [], extra = [] }) {
|
|
75
|
+
const outcome = run?.outcome ?? 'handoff'
|
|
76
|
+
const signal = run?.signal && run.signal !== 'none' ? ` (${run.signal})` : ''
|
|
77
|
+
const progressLines = progress.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(-40)
|
|
78
|
+
const diffLine = diff
|
|
79
|
+
? `Diff since leg start: ${diff.files ?? 0} file(s) changed${diff.head_at_start && diff.head && diff.head !== diff.head_at_start ? `, HEAD moved ${diff.head_at_start.slice(0, 7)} → ${diff.head.slice(0, 7)}` : ''}${diffStat ? `; ${diffStat.trim().split(/\r?\n/).pop()}` : ''}`
|
|
80
|
+
: 'Diff since leg start: (no git evidence)'
|
|
81
|
+
const lines = [
|
|
82
|
+
'## Scope',
|
|
83
|
+
'',
|
|
84
|
+
`Task: ${card.task}`,
|
|
85
|
+
`Card ${card.card_id}, station ${station.name}, leg ${leg} (${entry?.adapter ?? run?.adapter ?? 'agent'}) stopped with outcome ${outcome}${signal}. The next agent resumes in the same worktree.`,
|
|
86
|
+
'',
|
|
87
|
+
'## Projects mentioned',
|
|
88
|
+
'',
|
|
89
|
+
`- ${card.card_id}`,
|
|
90
|
+
'',
|
|
91
|
+
'## Findings',
|
|
92
|
+
'',
|
|
93
|
+
...bullets(progressLines.length ? progressLines.map((l, i) => `Done so far ${i + 1}: ${l}`) : ['Done so far: (no PROGRESS.md entries from the previous agent)']),
|
|
94
|
+
...bullets([lastMessage ? `Last message from the previous agent: ${scrub(String(lastMessage)).slice(0, 600)}` : null]),
|
|
95
|
+
...bullets([diffLine]),
|
|
96
|
+
...bullets(changedFiles.slice(0, 50).map((f) => `Diff touches ${f}`)),
|
|
97
|
+
'',
|
|
98
|
+
'## Opportunities',
|
|
99
|
+
'',
|
|
100
|
+
...bullets([
|
|
101
|
+
'Next agent: read .leg/PROGRESS.md and .leg/CONTRACT.md, continue from the last done step, then write .leg/DONE.',
|
|
102
|
+
progressLines.length ? null : 'Next agent: the previous run left no progress notes; check the diff first.',
|
|
103
|
+
]),
|
|
104
|
+
'',
|
|
105
|
+
'## Open questions',
|
|
106
|
+
'',
|
|
107
|
+
...bullets([
|
|
108
|
+
`Open findings: previous leg ended ${outcome}${signal}: ${run?.reason ?? 'no reason recorded'}`,
|
|
109
|
+
run?.exit_code !== undefined && run?.exit_code !== null ? `Open findings: exit code ${run.exit_code}` : null,
|
|
110
|
+
...extra.map((e) => `Open findings: ${e}`),
|
|
111
|
+
]),
|
|
112
|
+
'',
|
|
113
|
+
'## Evidence anchors',
|
|
114
|
+
'',
|
|
115
|
+
...bullets(changedFiles.slice(0, 50)),
|
|
116
|
+
...bullets(['.leg/PROGRESS.md', '.leg/CONTRACT.md']),
|
|
117
|
+
'',
|
|
118
|
+
]
|
|
119
|
+
return scrub(lines.join('\n'))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function tailFile(path, lines = 15) {
|
|
123
|
+
if (!existsSync(path)) return ''
|
|
124
|
+
return readFileSync(path, 'utf8').trim().split('\n').slice(-lines).join(' | ')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function ensureExcluded(repo, pattern) {
|
|
128
|
+
const f = join(repo, '.git', 'info', 'exclude')
|
|
129
|
+
try {
|
|
130
|
+
const cur = existsSync(f) ? readFileSync(f, 'utf8') : ''
|
|
131
|
+
if (!cur.split(/\r?\n/).includes(pattern)) appendFileSync(f, `${cur.endsWith('\n') || !cur ? '' : '\n'}${pattern}\n`)
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Writes the notes, saves the bundle repo-local in the worktree, validates it.
|
|
136
|
+
// Returns { bundle_id, path, notes_path, quality, score } (throws on failure).
|
|
137
|
+
export function writeHandoff({ card, station, leg, entry, run, worktree, runDir, extra = [], changedFiles = [], diffStat = '' }) {
|
|
138
|
+
const pPath = existsSync(join(worktree, '.leg', 'PROGRESS.md')) ? join(worktree, '.leg', 'PROGRESS.md') : join(worktree, '.baton', 'PROGRESS.md');
|
|
139
|
+
const progress = existsSync(pPath) ? readFileSync(pPath, 'utf8') : '';
|
|
140
|
+
let lastMessage = null
|
|
141
|
+
if (runDir && existsSync(join(runDir, 'last.md'))) lastMessage = readFileSync(join(runDir, 'last.md'), 'utf8')
|
|
142
|
+
if (!lastMessage && run?.last_message) lastMessage = run.last_message
|
|
143
|
+
const stderrTail = runDir ? tailFile(join(runDir, 'err.log')) : ''
|
|
144
|
+
const notes = buildNotes({
|
|
145
|
+
card, station, leg, entry, run, progress, lastMessage, diff: run?.diff ?? null, diffStat, changedFiles,
|
|
146
|
+
extra: [...extra, stderrTail && run?.exit_code !== 0 ? `stderr tail: ${scrub(stderrTail).slice(0, 800)}` : null].filter(Boolean),
|
|
147
|
+
})
|
|
148
|
+
const legDir = join(worktree, '.leg')
|
|
149
|
+
mkdirSync(legDir, { recursive: true })
|
|
150
|
+
const notesPath = join(legDir, `handoff-${station.name}-leg${leg}.md`)
|
|
151
|
+
writeFileSync(notesPath, notes)
|
|
152
|
+
if (card.repo) ensureExcluded(card.repo, '.context-handoffs/')
|
|
153
|
+
const slug = `baton-${card.card_id}-${station.name}-leg${leg}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80)
|
|
154
|
+
const title = `baton ${card.card_id} ${station.name} leg ${leg} ${entry?.adapter ?? run?.adapter ?? 'agent'}`
|
|
155
|
+
const save = chb(['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', 'baton'], { cwd: worktree })
|
|
156
|
+
if (save.status !== 0) throw new Error(`context-handoff-bundle save failed (exit ${save.status}): ${scrub(save.stderr || save.stdout).slice(0, 500)}`)
|
|
157
|
+
let out
|
|
158
|
+
try { out = JSON.parse(save.stdout) } catch { throw new Error(`context-handoff-bundle save printed no JSON: ${scrub(save.stdout).slice(0, 300)}`) }
|
|
159
|
+
const bundleId = out.bundle_id
|
|
160
|
+
const bundlePath = join(worktree, '.context-handoffs', bundleId)
|
|
161
|
+
const validate = chb(['validate', bundlePath], { cwd: worktree })
|
|
162
|
+
if (validate.status !== 0) throw new Error(`context-handoff-bundle validate failed: ${scrub(validate.stderr || validate.stdout).slice(0, 500)}`)
|
|
163
|
+
return { bundle_id: bundleId, path: bundlePath, notes_path: notesPath, quality: out.quality ?? null, score: out.score ?? null }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The resume text the next leg's prompt starts with.
|
|
167
|
+
export function loadResume(worktree, query = 'latest') {
|
|
168
|
+
const r = chb(['load', query], { cwd: worktree })
|
|
169
|
+
if (r.status !== 0) throw new Error(`context-handoff-bundle load failed (exit ${r.status}): ${scrub(r.stderr || r.stdout).slice(0, 500)}`)
|
|
170
|
+
return r.stdout
|
|
171
|
+
}
|
package/src/hook.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hook — the process Claude Code runs for a Baton session's hooks and status
|
|
3
|
+
// line (wired by src/taps/claude.mjs through `--settings`). Reads the JSON
|
|
4
|
+
// payload on stdin, updates the session record, exits 0 always: a broken hook
|
|
5
|
+
// must never stall the user's session.
|
|
6
|
+
// node hook.mjs claude-hook --session <id>
|
|
7
|
+
// node hook.mjs claude-statusline --session <id>
|
|
8
|
+
// The status line entry records rate_limits when a Claude Code build runs it
|
|
9
|
+
// (2.1.268 does not; see src/taps/claude-usage.mjs) and prints one Baton line.
|
|
10
|
+
import { appendFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { handleHook, handleStatusline } from './taps/claude.mjs'
|
|
13
|
+
import { sessionDir } from './sessions.mjs'
|
|
14
|
+
import { captureLive } from './live-capture.mjs'
|
|
15
|
+
|
|
16
|
+
function readStdin() {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let d = ''
|
|
19
|
+
const t = setTimeout(() => resolve(d), 4000)
|
|
20
|
+
process.stdin.setEncoding('utf8')
|
|
21
|
+
process.stdin.on('data', (c) => { d += c })
|
|
22
|
+
process.stdin.on('end', () => { clearTimeout(t); resolve(d) })
|
|
23
|
+
process.stdin.on('error', () => { clearTimeout(t); resolve(d) })
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const [kind, ...rest] = process.argv.slice(2)
|
|
28
|
+
const i = rest.indexOf('--session')
|
|
29
|
+
const sessionId = i !== -1 ? rest[i + 1] : null
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readStdin()
|
|
33
|
+
let payload = {}
|
|
34
|
+
try { payload = JSON.parse(raw) } catch {}
|
|
35
|
+
if (!sessionId) process.exit(0)
|
|
36
|
+
if (kind === 'claude-hook') {
|
|
37
|
+
const line = handleHook(sessionId, payload)
|
|
38
|
+
try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} ${payload.hook_event_name ?? '?'} ${line}\n`) } catch {}
|
|
39
|
+
// the first real StopFailure per error kind is kept as evidence (never a simulated one)
|
|
40
|
+
if (payload.hook_event_name === 'StopFailure' && payload.error) {
|
|
41
|
+
try { captureLive('claude', String(payload.error), payload, { sessionId }) } catch {}
|
|
42
|
+
}
|
|
43
|
+
} else if (kind === 'claude-statusline') {
|
|
44
|
+
const { text } = handleStatusline(sessionId, payload)
|
|
45
|
+
try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
|
|
46
|
+
process.stdout.write(text + '\n')
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
process.exit(0)
|
package/src/land.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// land — the land station handler the orchestrator calls, and the Land button
|
|
2
|
+
// on a terminal card. `ff` mode runs the merge queue (src/mergequeue.mjs); `pr`
|
|
3
|
+
// mode opens a pull request through the gh stub (src/stations/pr.mjs) and
|
|
4
|
+
// parks the card for a human. A terminal session in its own worktree lands its
|
|
5
|
+
// branch (baton/<session-id>) through the same queue.
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { land } from './mergequeue.mjs'
|
|
10
|
+
import { openPr } from './stations/pr.mjs'
|
|
11
|
+
import { cardDir, ledgerAppend } from './store.mjs'
|
|
12
|
+
import { remove as removeWorktree, ensureExcludeEntries } from './worktree.mjs'
|
|
13
|
+
import { appendEvent, writeLand, appendLanding } from './sessions.mjs'
|
|
14
|
+
import { scrub } from './redact.mjs'
|
|
15
|
+
|
|
16
|
+
export async function landCard(card, worktree) {
|
|
17
|
+
const warn = (msg) => ledgerAppend(card.card_id, { type: 'land_warning', station: card.station, leg: 0, summary: msg })
|
|
18
|
+
if (card.land_mode === 'pr') {
|
|
19
|
+
const r = openPr({ card: { ...card, worktree }, runDir: join(cardDir(card.card_id), 'land') })
|
|
20
|
+
if (!r.ok) return { landed: false, bounced: false, pr: false, reason: `pr mode: ${r.error}` }
|
|
21
|
+
return { landed: false, pr: true, url: r.url, summary: `pull request opened: ${r.url ?? '(no url)'}`, argv: r.argv }
|
|
22
|
+
}
|
|
23
|
+
return land(card, worktree, { onWarning: warn })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---- terminals ----
|
|
27
|
+
const inFlight = new Set()
|
|
28
|
+
export function landingNow(id) { return inFlight.has(id) }
|
|
29
|
+
|
|
30
|
+
function git(cwd, args) {
|
|
31
|
+
const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
32
|
+
return { ok: r.status === 0, out: (r.stdout ?? '').trim() }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Why this session cannot land right now, or null.
|
|
36
|
+
export function landBlocker(s) {
|
|
37
|
+
if (!s.worktree) return 'this terminal works in the checkout itself: there is no branch of its own to land'
|
|
38
|
+
if (!s.worktree.base) return `${s.worktree.branch} was cut from a detached HEAD: there is no branch to land it onto`
|
|
39
|
+
if (!existsSync(s.worktree.path)) return `the worktree ${s.worktree.path} is gone`
|
|
40
|
+
if (inFlight.has(s.session_id)) return 'already landing'
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The Land button: commit what the agent left in the worktree, rebase the
|
|
45
|
+
// branch onto its base, run the tests, fast-forward the base (never a merge
|
|
46
|
+
// commit); a bounce carries the reason. land.json holds the card's state,
|
|
47
|
+
// landings.jsonl who landed what. Resolves with the queue's result.
|
|
48
|
+
export async function landSession(session, { by = 'local' } = {}) {
|
|
49
|
+
const id = session.session_id
|
|
50
|
+
const { path, branch, base } = session.worktree
|
|
51
|
+
inFlight.add(id)
|
|
52
|
+
writeLand(id, { state: 'landing', at: new Date().toISOString(), by, branch, base })
|
|
53
|
+
appendEvent(id, { type: 'land_requested', by, summary: `land requested by ${by}: ${branch} onto ${base}` })
|
|
54
|
+
let r
|
|
55
|
+
try {
|
|
56
|
+
await new Promise((res) => setImmediate(res)) // the caller answers the request first
|
|
57
|
+
ensureExcludeEntries(session.repo) // the commit step adds everything else the agent left
|
|
58
|
+
const task = String(session.task ?? 'terminal session').split('\n')[0].slice(0, 60)
|
|
59
|
+
r = await land({ card_id: id, repo: session.repo, trunk: base, title: `${session.agent} ${id.split('-').pop()}: ${task}`, test_command: null }, path, {
|
|
60
|
+
allowDirtyRoot: true,
|
|
61
|
+
onWarning: (msg) => appendEvent(id, { type: 'land_warning', by, summary: msg }),
|
|
62
|
+
})
|
|
63
|
+
} catch (err) {
|
|
64
|
+
r = { landed: false, bounced: true, reason: 'error', detail: String(err.message) }
|
|
65
|
+
} finally {
|
|
66
|
+
inFlight.delete(id)
|
|
67
|
+
}
|
|
68
|
+
const at = new Date().toISOString()
|
|
69
|
+
if (r.landed && r.sha === r.sha_before) {
|
|
70
|
+
writeLand(id, { state: 'noop', at, by, branch, base })
|
|
71
|
+
appendEvent(id, { type: 'land_noop', by, summary: `nothing to land: ${branch} has no changes beyond ${base}` })
|
|
72
|
+
} else if (r.landed) {
|
|
73
|
+
const commits = git(session.repo, ['rev-list', '--reverse', `${r.sha_before}..${r.sha}`]).out.split('\n').filter(Boolean)
|
|
74
|
+
const tested = Boolean(r.tests)
|
|
75
|
+
appendLanding({ repo: session.repo, trunk: base, session_id: id, agent: session.agent, account: session.account, by, sha: r.sha, sha_before: r.sha_before, commits, files: r.files, insertions: r.insertions, deletions: r.deletions, tested })
|
|
76
|
+
writeLand(id, { state: 'landed', at, by, branch, base, sha: r.sha, files: r.files, insertions: r.insertions, deletions: r.deletions, tested, summary: r.summary })
|
|
77
|
+
appendEvent(id, { type: 'landed', by, summary: `${r.summary} · Land pressed by ${by}` })
|
|
78
|
+
} else {
|
|
79
|
+
const detail = scrub(String(r.detail ?? '')).slice(0, 4000)
|
|
80
|
+
writeLand(id, { state: 'bounced', at, by, branch, base, reason: r.reason, detail, files: r.files ?? [] })
|
|
81
|
+
appendEvent(id, { type: 'bounced', by, summary: `land bounced (${r.reason}): ${detail.split('\n')[0].slice(0, 200)}`, body: detail })
|
|
82
|
+
}
|
|
83
|
+
return r
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Removing a finished session takes its worktree and branch with it only when
|
|
87
|
+
// nothing is lost: a clean worktree whose branch is already in its base.
|
|
88
|
+
export function pruneSessionWorktree(s) {
|
|
89
|
+
const wt = s.worktree
|
|
90
|
+
if (!wt || !existsSync(wt.path)) return { removed: false, reason: 'no worktree on disk' }
|
|
91
|
+
const status = git(wt.path, ['status', '--porcelain'])
|
|
92
|
+
// a status check that fails must not read as clean
|
|
93
|
+
if (!status.ok) return { removed: false, reason: `cannot verify ${wt.path}: git status failed` }
|
|
94
|
+
if (status.out) return { removed: false, reason: `uncommitted changes in ${wt.path}` }
|
|
95
|
+
// "already landed" is true not just for a fast-forward ancestor but also for a
|
|
96
|
+
// squash- or rebase-merge and a cherry-pick: git cherry marks a commit whose
|
|
97
|
+
// patch is already upstream with '-', so no '+' line means the work is in base
|
|
98
|
+
const cherry = wt.base ? git(s.repo, ['cherry', wt.base, wt.branch]) : { ok: false, out: '' }
|
|
99
|
+
const ancestor = wt.base && git(s.repo, ['merge-base', '--is-ancestor', wt.branch, wt.base]).ok
|
|
100
|
+
const merges = wt.base ? git(s.repo, ['rev-list', '--merges', `${wt.base}..${wt.branch}`]) : { ok: false, out: '' }
|
|
101
|
+
const patchEquivalent = cherry.ok && merges.ok && !merges.out && !cherry.out.split('\n').some((l) => l.startsWith('+'))
|
|
102
|
+
const merged = wt.base && (ancestor || patchEquivalent)
|
|
103
|
+
if (!merged) return { removed: false, reason: `${wt.branch} has commits that are not on ${wt.base ?? 'any branch'}` }
|
|
104
|
+
const removed = removeWorktree(s.repo, s.session_id, { deleteBranch: true })
|
|
105
|
+
return { removed: removed.removed, branchDeleted: removed.branchDeleted, branch: wt.branch }
|
|
106
|
+
}
|