@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/bundle.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// bundle — keeps a context-handoff-bundle current for an interactive session
|
|
2
|
+
// and turns it into the prompt the next agent starts from. Reuses the v0.1
|
|
3
|
+
// seam (src/handoff.mjs: chb(), resolveChb) so the CLI is still the only
|
|
4
|
+
// writer of bundle files. One bundle per session (`save --update <slug>`).
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { chb, ensureExcluded } from './handoff.mjs'
|
|
9
|
+
import { scrub } from './redact.mjs'
|
|
10
|
+
import { updateSession, workRoot } from './sessions.mjs'
|
|
11
|
+
import { perSessionFile, writeHandoffPointer } from './resume.mjs'
|
|
12
|
+
|
|
13
|
+
const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
|
|
14
|
+
const bullets = (items) => items.filter(Boolean).map((x) => `- ${String(x).replace(/\r?\n/g, ' ').trim()}`)
|
|
15
|
+
|
|
16
|
+
function git(cwd, args) {
|
|
17
|
+
const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
18
|
+
// trimEnd only: a porcelain line starts with a space (" M README.md")
|
|
19
|
+
return r.status === 0 ? r.stdout.trimEnd() : ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function slugFor(session) { return `leg-${session.session_id}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80) }
|
|
23
|
+
|
|
24
|
+
// Notes in the CLI's section vocabulary; see src/handoff.mjs buildNotes.
|
|
25
|
+
export function sessionNotes(session, { messages = [], why = 'handoff' } = {}) {
|
|
26
|
+
const cwd = workRoot(session)
|
|
27
|
+
const stat = git(cwd, ['diff', '--stat'])
|
|
28
|
+
const dirty = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, '')).filter((f) => !LEG_DIRS.test(f)).slice(0, 60)
|
|
29
|
+
const recent = git(cwd, ['log', '--oneline', '-5'])
|
|
30
|
+
const lines = [
|
|
31
|
+
'## Scope', '',
|
|
32
|
+
`Task: ${session.task ?? '(no prompt recorded yet; read the transcript)'}`,
|
|
33
|
+
`Interactive ${session.agent} session ${session.session_id} in ${session.cwd}${session.branch ? ` on branch ${session.branch}` : ''} stopped: ${why}. The next agent continues in the same directory.`,
|
|
34
|
+
'', '## Projects mentioned', '', `- ${session.repo_name ?? session.cwd}`, '',
|
|
35
|
+
'## Findings', '',
|
|
36
|
+
...bullets(messages.slice(-8).map((m, i) => `${m.role === 'user' ? 'Human said' : 'Agent said'} (${i + 1}): ${m.text.slice(0, 600)}`)),
|
|
37
|
+
...bullets([`Diff since session start: ${stat ? stat.split('\n').pop() : 'clean working tree'}`]),
|
|
38
|
+
...bullets(dirty.map((f) => `Dirty file: ${f}`)),
|
|
39
|
+
...bullets((session.files_touched ?? []).slice(0, 50).map((f) => `Edited this session: ${f}`)),
|
|
40
|
+
...bullets(recent ? [`Recent commits: ${recent.replace(/\n/g, ' | ')}`] : []),
|
|
41
|
+
'', '## Opportunities', '',
|
|
42
|
+
...bullets(['Next agent: read this bundle, inspect `git status` and `git diff`, continue the task from the last agent message, and do not ask the human to restate the task.']),
|
|
43
|
+
'', '## Open questions', '',
|
|
44
|
+
...bullets([`Why the previous agent stopped: ${why}`, session.limit?.detail ? `Limit text: ${session.limit.detail}` : null]),
|
|
45
|
+
'', '## Evidence anchors', '',
|
|
46
|
+
...bullets([...dirty, ...(session.files_touched ?? [])].slice(0, 50)),
|
|
47
|
+
...bullets([session.transcript_path ? `transcript: ${session.transcript_path}` : null]),
|
|
48
|
+
'',
|
|
49
|
+
]
|
|
50
|
+
return scrub(lines.join('\n'))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Save (or refresh) the session's bundle. Returns { bundle_id, path } or throws.
|
|
54
|
+
export function saveSessionBundle(session, { messages = [], why = 'checkpoint' } = {}) {
|
|
55
|
+
// a session in its own worktree keeps its bundle and RESUME.md there, where the next agent starts
|
|
56
|
+
const cwd = workRoot(session)
|
|
57
|
+
const legDir = join(cwd, '.leg')
|
|
58
|
+
mkdirSync(legDir, { recursive: true })
|
|
59
|
+
if (session.repo) for (const pat of ['.leg/', '.baton/', '.context-handoffs/']) ensureExcluded(session.repo, pat)
|
|
60
|
+
const notesPath = join(legDir, `session-${session.session_id}.md`)
|
|
61
|
+
writeFileSync(notesPath, sessionNotes(session, { messages, why }))
|
|
62
|
+
const slug = slugFor(session)
|
|
63
|
+
const title = `leg ${session.agent} session ${session.session_id}`
|
|
64
|
+
const base = ['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', 'leg', '--tag', session.agent]
|
|
65
|
+
let r = session.bundle?.id ? chb([...base, '--update', slug], { cwd }) : { status: 1 }
|
|
66
|
+
if (r.status !== 0) r = chb(base, { cwd })
|
|
67
|
+
if (r.status !== 0) throw new Error(`context-handoff-bundle save failed (exit ${r.status}): ${scrub(r.stderr || r.stdout).slice(0, 400)}`)
|
|
68
|
+
let out
|
|
69
|
+
try { out = JSON.parse(r.stdout) } catch { throw new Error(`context-handoff-bundle save printed no JSON: ${scrub(r.stdout).slice(0, 200)}`) }
|
|
70
|
+
const bundle = { id: out.bundle_id, path: join(cwd, '.context-handoffs', out.bundle_id), notes: notesPath, quality: out.quality ?? null, updated_at: new Date().toISOString(), why }
|
|
71
|
+
updateSession(session.session_id, { bundle })
|
|
72
|
+
return bundle
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The resume text for the next agent: chb load into .baton/RESUME.md, plus a
|
|
76
|
+
// short pointer prompt (argv stays small; the bundle carries the context).
|
|
77
|
+
export function resumePrompt(session, bundle, next) {
|
|
78
|
+
const cwd = workRoot(session)
|
|
79
|
+
let loaded = ''
|
|
80
|
+
try {
|
|
81
|
+
const r = chb(['load', bundle.id], { cwd })
|
|
82
|
+
if (r.status === 0) loaded = r.stdout
|
|
83
|
+
} catch {}
|
|
84
|
+
const header = `# Leg handoff\n\nPrevious agent: ${session.agent} (${session.account}). Reason: ${session.limit?.reason ?? session.handoff?.reason ?? 'handoff requested'}${session.limit?.detail ? `, ${session.limit.detail}` : ''}.\nNext agent: ${next.agent} (${next.account}).\nBundle: ${bundle.path}\n\n`
|
|
85
|
+
const body = header + (loaded || readFileSync(bundle.notes, 'utf8'))
|
|
86
|
+
// src/resume.mjs owns both files: the per-session one so two sessions sharing
|
|
87
|
+
// one checkout (--no-worktree, or two started in the same instant) never
|
|
88
|
+
// overwrite each other's handoff, and RESUME.md, the copy everyone opens.
|
|
89
|
+
// Both are stamped with the git state and the live terminals they describe,
|
|
90
|
+
// so `baton resume --check` can tell a reader when they stopped being true.
|
|
91
|
+
const why = session.limit?.reason ?? session.handoff?.reason ?? 'handoff requested'
|
|
92
|
+
writeHandoffPointer(session, body, { bundle, why })
|
|
93
|
+
const perSession = perSessionFile(cwd, session.session_id)
|
|
94
|
+
const task = session.task ? `\n\nThe task, as the human first stated it: ${session.task.slice(0, 700)}` : ''
|
|
95
|
+
// the absolute path: the next agent is spawned in the session's cwd, which is
|
|
96
|
+
// a subdirectory of the work root whenever Baton was started in one
|
|
97
|
+
return `You are taking over an interactive coding session from ${session.agent}, which hit its usage limit. Read ${perSession} (the context handoff bundle is at ${bundle.path}), check git status and git diff, then continue the work from where it stopped. Do not ask the human to restate the task.${task}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Freshness, never existence: src/resume.mjs recomputes it from git at read
|
|
101
|
+
// time. `resumeFileExists` used to live here and answered "a file is on disk",
|
|
102
|
+
// which every caller then read as "the handoff it describes is still true".
|
|
103
|
+
export { resumeVerdict } from './resume.mjs'
|
package/src/cards.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// cards — create a card from loosely typed input (CLI flags or a JSON body):
|
|
2
|
+
// parse the chain, build and validate the pipeline, then hand the ledger the
|
|
3
|
+
// exact shape. Shared by bin/baton.mjs and src/server.mjs.
|
|
4
|
+
import { execFileSync } from 'node:child_process'
|
|
5
|
+
import { existsSync, statSync } from 'node:fs'
|
|
6
|
+
import { resolve, sep, join } from 'node:path'
|
|
7
|
+
import { canonPath } from './fsx.mjs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { buildPipeline, validatePipeline, loadAdapterModes, parseChain } from './pipeline.mjs'
|
|
10
|
+
import { PRESET_NAMES } from './presets.mjs'
|
|
11
|
+
import { ledgerCreate, readCard } from './store.mjs'
|
|
12
|
+
import { humanAction } from './orchestrator.mjs'
|
|
13
|
+
|
|
14
|
+
export class CardInputError extends Error {}
|
|
15
|
+
|
|
16
|
+
function samePath(a, b) {
|
|
17
|
+
return canonPath(a) === canonPath(b)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isParentOf(parent, child) {
|
|
21
|
+
return canonPath(child).startsWith(canonPath(parent) + sep)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function list(v) {
|
|
25
|
+
if (Array.isArray(v)) return v.map(String).map((x) => x.trim()).filter(Boolean)
|
|
26
|
+
return String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// "adapter=value,adapter=value" or {adapter: value}
|
|
30
|
+
function kv(raw) {
|
|
31
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw
|
|
32
|
+
const m = {}
|
|
33
|
+
for (const part of list(raw)) {
|
|
34
|
+
const eq = part.indexOf('=')
|
|
35
|
+
if (eq <= 0) throw new CardInputError(`expected adapter=value, got "${part}"`)
|
|
36
|
+
m[part.slice(0, eq).trim()] = part.slice(eq + 1)
|
|
37
|
+
}
|
|
38
|
+
return m
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function createCard(input, actor = { type: 'human', id: 'local' }) {
|
|
42
|
+
if (!input.repo) throw new CardInputError('missing repo')
|
|
43
|
+
// stored as given (resolved); every comparison below is canonical, and the
|
|
44
|
+
// worktree path is derived from the real long form in src/worktree.mjs
|
|
45
|
+
const repo = resolve(String(input.repo))
|
|
46
|
+
if (!existsSync(repo)) throw new CardInputError(`repo not found: ${repo}`)
|
|
47
|
+
if (!statSync(repo).isDirectory()) throw new CardInputError(`repo is not a directory: ${repo}`)
|
|
48
|
+
let top
|
|
49
|
+
try {
|
|
50
|
+
top = execFileSync('git', ['-C', repo, 'rev-parse', '--show-toplevel'], { windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } }).trim()
|
|
51
|
+
} catch {
|
|
52
|
+
throw new CardInputError(`repo is not a git repository: ${repo}`)
|
|
53
|
+
}
|
|
54
|
+
const resolvedTop = resolve(top)
|
|
55
|
+
if (!samePath(resolvedTop, repo)) throw new CardInputError(`repo is not the repository root: ${repo} (root is ${resolvedTop})`)
|
|
56
|
+
// the trunk must exist now: a card whose trunk is missing would otherwise be
|
|
57
|
+
// refused by the worktree step on every scheduler tick, an error event each time
|
|
58
|
+
const trunk = String(input.trunk || 'main')
|
|
59
|
+
const gitq = (args) => { try { return execFileSync('git', ['-C', repo, ...args], { windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' }, stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return null } }
|
|
60
|
+
if (gitq(['rev-parse', '--verify', '--quiet', `refs/heads/${trunk}`]) === null) {
|
|
61
|
+
const actual = gitq(['symbolic-ref', '--short', 'HEAD'])
|
|
62
|
+
throw new CardInputError(`trunk ${trunk} does not exist in ${repo}${actual ? `; this repo's default branch is ${actual} (add the card with --trunk ${actual})` : ''}`)
|
|
63
|
+
}
|
|
64
|
+
const legHome = resolve(process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg')))
|
|
65
|
+
if (samePath(repo, legHome) || isParentOf(repo, legHome)) throw new CardInputError('repo cannot contain LEG_HOME')
|
|
66
|
+
const task = String(input.task ?? '').trim()
|
|
67
|
+
if (!task) throw new CardInputError('missing task')
|
|
68
|
+
if (!input.chain || (Array.isArray(input.chain) && !input.chain.length)) throw new CardInputError('missing chain (e.g. claude,codex)')
|
|
69
|
+
let chain
|
|
70
|
+
try { chain = parseChain(input.chain) } catch (err) { throw new CardInputError(err.message) }
|
|
71
|
+
const modes = kv(input.mode)
|
|
72
|
+
const turns = kv(input.maxTurns ?? input.max_turns)
|
|
73
|
+
const fakeModes = kv(input.fakeMode ?? input.fake_mode)
|
|
74
|
+
const fakeFixtures = kv(input.fakeFixture ?? input.fake_fixture)
|
|
75
|
+
const fakeTargets = kv(input.fakeTarget ?? input.fake_target)
|
|
76
|
+
const fakeContents = kv(input.fakeContent ?? input.fake_content)
|
|
77
|
+
const models = kv(input.model)
|
|
78
|
+
const approve = list(input.approve)
|
|
79
|
+
chain = chain.map((e) => ({
|
|
80
|
+
...e,
|
|
81
|
+
...(modes[e.adapter] ? { mode: modes[e.adapter] } : {}),
|
|
82
|
+
...(turns[e.adapter] ? { maxTurns: parseInt(turns[e.adapter], 10) } : {}),
|
|
83
|
+
...(models[e.adapter] ? { model: models[e.adapter] } : {}),
|
|
84
|
+
...(fakeModes[e.adapter] ? { fakeMode: fakeModes[e.adapter] } : {}),
|
|
85
|
+
...(fakeFixtures[e.adapter] ? { fakeFixture: fakeFixtures[e.adapter] } : {}),
|
|
86
|
+
...(fakeTargets[e.adapter] ? { fakeTarget: fakeTargets[e.adapter] } : {}),
|
|
87
|
+
...(fakeContents[e.adapter] ? { fakeContent: fakeContents[e.adapter] } : {}),
|
|
88
|
+
...(approve.includes(e.adapter) ? { approve: true } : {}),
|
|
89
|
+
}))
|
|
90
|
+
const pipelineArg = input.pipeline ?? 'build'
|
|
91
|
+
let pipeline
|
|
92
|
+
try {
|
|
93
|
+
if (Array.isArray(pipelineArg)) pipeline = buildPipeline({ stations: pipelineArg, chain })
|
|
94
|
+
else if (typeof pipelineArg === 'string' && pipelineArg.trim().startsWith('[')) pipeline = buildPipeline({ stations: JSON.parse(pipelineArg), chain })
|
|
95
|
+
else if (PRESET_NAMES.includes(pipelineArg)) pipeline = buildPipeline({ preset: pipelineArg, chain })
|
|
96
|
+
else pipeline = buildPipeline({ file: pipelineArg, chain })
|
|
97
|
+
validatePipeline(pipeline, await loadAdapterModes())
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new CardInputError(`invalid pipeline: ${err.message}`)
|
|
100
|
+
}
|
|
101
|
+
const landMode = input.landMode ?? input.land_mode ?? 'ff'
|
|
102
|
+
if (!['ff', 'pr'].includes(landMode)) throw new CardInputError(`invalid land mode "${landMode}" (ff or pr)`)
|
|
103
|
+
const leases = list(input.leases)
|
|
104
|
+
const slug = (input.slug || task.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30)) || 'card'
|
|
105
|
+
const id = ledgerCreate({
|
|
106
|
+
slug, task, repo,
|
|
107
|
+
chain: chain.map((e) => ({ adapter: e.adapter, mode: e.mode ?? null, max_turns: e.maxTurns ?? null, ...(e.model ? { model: e.model } : {}) })),
|
|
108
|
+
pipeline, leases, trunk: input.trunk || 'main', 'land-mode': landMode,
|
|
109
|
+
'test-command': input.testCommand ?? input.test_command ?? null, title: input.title || null,
|
|
110
|
+
actor: JSON.stringify(actor),
|
|
111
|
+
})
|
|
112
|
+
if (input.queue) humanAction(id, 'enqueue', {}, actor)
|
|
113
|
+
return readCard(id)
|
|
114
|
+
}
|
package/src/chain.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// chain — the card state machine. Pure: `transition(card, action, payload)`
|
|
2
|
+
// returns the patched card plus the ledger events to append; no I/O. Two
|
|
3
|
+
// machines live here: the station machine (a card moves through its pipeline)
|
|
4
|
+
// and the chain machine (inside an agent station, legs hand off to the next
|
|
5
|
+
// adapter on limit / incomplete / no_progress / stalled / failed).
|
|
6
|
+
// TRANSITIONS is data so tests print the table and docs embed it.
|
|
7
|
+
import { stationIndex } from './pipeline.mjs'
|
|
8
|
+
|
|
9
|
+
export const TERMINAL = ['done', 'failed', 'killed']
|
|
10
|
+
export const NON_TERMINAL = ['backlog', 'queued', 'running', 'handing_off', 'waiting_human', 'needs_approval', 'paused']
|
|
11
|
+
|
|
12
|
+
// [from, action, to, note]
|
|
13
|
+
export const TRANSITIONS = [
|
|
14
|
+
['backlog', 'enqueue', 'queued', 'human adds the card to the floor (or `card run`)'],
|
|
15
|
+
['queued', 'start', 'running', 'scheduler starts a leg (agent/test/land station) under the card\'s leases'],
|
|
16
|
+
['queued', 'reach_human', 'waiting_human', 'a human station is next'],
|
|
17
|
+
['running', 'leg:completed', 'queued', 'agent leg completed → next station (leg 0)'],
|
|
18
|
+
['running', 'leg:completed', 'done', 'agent leg completed and it was the last station'],
|
|
19
|
+
['running', 'leg:completed', 'waiting_human', 'agent leg completed and a human station is next'],
|
|
20
|
+
['running', 'leg:handoff', 'handing_off', 'limit | incomplete | no_progress | stalled | failed and the chain has a next leg'],
|
|
21
|
+
['running', 'leg:handoff', 'failed', 'same outcomes with the chain exhausted'],
|
|
22
|
+
['running', 'leg:auth_failed', 'failed', 'Baton/environment fault; no advance; human fixes and clicks Rerun'],
|
|
23
|
+
['running', 'leg:launch_failed', 'failed', 'same'],
|
|
24
|
+
['running', 'leg:killed', 'killed', 'the leg was killed from the board'],
|
|
25
|
+
['handing_off', 'bundle_written', 'queued', 'next leg (leg+1) queued at the same station'],
|
|
26
|
+
['handing_off', 'bundle_written', 'needs_approval', 'next chain entry has approve: true'],
|
|
27
|
+
['running', 'test:green', 'queued', 'test station passed → next station'],
|
|
28
|
+
['running', 'test:green', 'done', 'test station passed and it was last'],
|
|
29
|
+
['running', 'test:red', 'queued', 'bounce to the nearest earlier build station (or the first agent station) with bounce_reason'],
|
|
30
|
+
['running', 'land:landed', 'done', 'land station merged (phase 7: real merge queue)'],
|
|
31
|
+
['running', 'land:bounced', 'queued', 'land red/conflict → bounce to build with the failure attached (phase 7)'],
|
|
32
|
+
['running', 'land:failed', 'failed', 'land attempts exhausted (phase 7)'],
|
|
33
|
+
['running', 'pause', 'paused', 'human: kill the child, write a bundle'],
|
|
34
|
+
['paused', 'resume', 'queued', 'human: same station and leg; prompt = bundle load + contract'],
|
|
35
|
+
['*non-terminal*', 'kill', 'killed', 'human'],
|
|
36
|
+
['*non-terminal*', 'reassign', 'queued', 'human: rewrite the current station\'s chain from the current leg'],
|
|
37
|
+
['running', 'handoff_now', 'handing_off', 'human: as if the leg ended incomplete'],
|
|
38
|
+
['needs_approval', 'approve', 'queued', 'human'],
|
|
39
|
+
['waiting_human', 'approve', 'queued', 'human: the human station is done → next station'],
|
|
40
|
+
['waiting_human', 'approve', 'done', 'human station was last'],
|
|
41
|
+
['*terminal*', 'rerun', 'queued', 'human: station 0, leg 0, same worktree, new run number'],
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
export class IllegalTransition extends Error {
|
|
45
|
+
constructor(from, action, to) {
|
|
46
|
+
super(`illegal transition ${from} --${action}--> ${to ?? '?'}`)
|
|
47
|
+
this.name = 'IllegalTransition'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const HANDOFF_OUTCOMES = ['limit', 'incomplete', 'no_progress', 'stalled', 'failed']
|
|
52
|
+
const NO_ADVANCE_OUTCOMES = ['auth_failed', 'launch_failed']
|
|
53
|
+
|
|
54
|
+
function station(card) {
|
|
55
|
+
const i = stationIndex(card.pipeline, card.station)
|
|
56
|
+
return { i, s: card.pipeline[i] ?? null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ev(type, summary, body) {
|
|
60
|
+
const e = { type, summary }
|
|
61
|
+
if (body) e.body = body
|
|
62
|
+
return e
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Where the card goes after the station at index i is done.
|
|
66
|
+
function advance(card, i, events) {
|
|
67
|
+
const next = card.pipeline[i + 1]
|
|
68
|
+
events.push(ev('station_done', `station ${card.pipeline[i].name} done${next ? ` → ${next.name}` : ''}`))
|
|
69
|
+
if (!next) {
|
|
70
|
+
events.push(ev('done', `card done: all ${card.pipeline.length} station(s) complete`))
|
|
71
|
+
return { ...card, status: 'done', leg: 0 }
|
|
72
|
+
}
|
|
73
|
+
if (next.kind === 'human') return { ...card, station: next.name, leg: 0, status: 'waiting_human' }
|
|
74
|
+
return { ...card, station: next.name, leg: 0, status: 'queued' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The agent station a red test or a bounced land goes back to: the nearest
|
|
78
|
+
// earlier `build`, else the first agent station BEFORE the failing one. An
|
|
79
|
+
// agent station after it is never a target: the card would skip the station
|
|
80
|
+
// it just failed and finish `done` with red tests.
|
|
81
|
+
function bounceTarget(card, i) {
|
|
82
|
+
for (let k = i - 1; k >= 0; k--) {
|
|
83
|
+
const s = card.pipeline[k]
|
|
84
|
+
if (s.kind === 'agent' && s.name === 'build') return s
|
|
85
|
+
}
|
|
86
|
+
return card.pipeline.slice(0, i).find((s) => s.kind === 'agent') ?? null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function assertStatus(card, action, allowed) {
|
|
90
|
+
if (!allowed.includes(card.status)) throw new IllegalTransition(card.status, action)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function transition(card, action, payload = {}) {
|
|
94
|
+
const events = []
|
|
95
|
+
const { i, s } = station(card)
|
|
96
|
+
|
|
97
|
+
switch (action) {
|
|
98
|
+
case 'enqueue': {
|
|
99
|
+
assertStatus(card, action, ['backlog'])
|
|
100
|
+
const first = card.pipeline[0]
|
|
101
|
+
if (!first) throw new IllegalTransition(card.status, action, 'queued')
|
|
102
|
+
// No ledger event: the status change itself is the record (the board
|
|
103
|
+
// reads card.json); events are reserved for things that happened.
|
|
104
|
+
if (first.kind === 'human') return { card: { ...card, station: first.name, leg: 0, status: 'waiting_human' }, events: [] }
|
|
105
|
+
return { card: { ...card, station: first.name, leg: 0, status: 'queued' }, events: [] }
|
|
106
|
+
}
|
|
107
|
+
case 'start': {
|
|
108
|
+
assertStatus(card, action, ['queued'])
|
|
109
|
+
if (!s) throw new IllegalTransition(card.status, action, 'running')
|
|
110
|
+
if (s.kind === 'human') return { card: { ...card, status: 'waiting_human' }, events: [] }
|
|
111
|
+
return { card: { ...card, status: 'running' }, events: [] }
|
|
112
|
+
}
|
|
113
|
+
case 'leg_result': {
|
|
114
|
+
// payload: { outcome, handoff, signal, adapter, run }
|
|
115
|
+
assertStatus(card, action, ['running'])
|
|
116
|
+
if (!s || s.kind !== 'agent') throw new IllegalTransition(card.status, action)
|
|
117
|
+
const { outcome } = payload
|
|
118
|
+
const tag = `${payload.adapter ?? s.chain[card.leg]?.adapter} leg ${card.leg}${payload.signal && payload.signal !== 'none' ? ` (${payload.signal})` : ''}`
|
|
119
|
+
if (outcome === 'completed') return { card: advance(card, i, events), events }
|
|
120
|
+
if (outcome === 'killed') {
|
|
121
|
+
events.push(ev('killed', `${tag} killed`))
|
|
122
|
+
return { card: { ...card, status: 'killed' }, events }
|
|
123
|
+
}
|
|
124
|
+
if (NO_ADVANCE_OUTCOMES.includes(outcome)) {
|
|
125
|
+
events.push(ev('failed', `${tag}: ${outcome}; not advancing (fix the environment and rerun)`))
|
|
126
|
+
return { card: { ...card, status: 'failed', failure: outcome }, events }
|
|
127
|
+
}
|
|
128
|
+
if (HANDOFF_OUTCOMES.includes(outcome)) {
|
|
129
|
+
const next = s.chain[card.leg + 1]
|
|
130
|
+
if (!next) {
|
|
131
|
+
events.push(ev('failed', `${tag}: ${outcome}; chain exhausted at ${s.name}`))
|
|
132
|
+
return { card: { ...card, status: 'failed', failure: outcome }, events }
|
|
133
|
+
}
|
|
134
|
+
return { card: { ...card, status: 'handing_off', handoff_outcome: outcome, next_leg: card.leg + 1 }, events }
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`unknown leg outcome "${outcome}"`)
|
|
137
|
+
}
|
|
138
|
+
case 'bundle_written': {
|
|
139
|
+
assertStatus(card, action, ['handing_off'])
|
|
140
|
+
const nextLeg = card.next_leg ?? card.leg + 1
|
|
141
|
+
const entry = s?.chain?.[nextLeg]
|
|
142
|
+
if (!entry) throw new IllegalTransition(card.status, action)
|
|
143
|
+
const from = s.chain[card.leg]?.adapter
|
|
144
|
+
if (entry.approve) {
|
|
145
|
+
events.push(ev('approval_needed', `${from} → ${entry.adapter} needs approval before leg ${nextLeg}`))
|
|
146
|
+
return { card: { ...card, leg: nextLeg, status: 'needs_approval', next_leg: null }, events }
|
|
147
|
+
}
|
|
148
|
+
// The handoff_written event (orchestrator) already names from → to; the
|
|
149
|
+
// next leg_started (supervisor) names the adapter. No extra event here.
|
|
150
|
+
void from
|
|
151
|
+
return { card: { ...card, leg: nextLeg, status: 'queued', next_leg: null }, events }
|
|
152
|
+
}
|
|
153
|
+
case 'test_result': {
|
|
154
|
+
assertStatus(card, action, ['running'])
|
|
155
|
+
if (!s || s.kind !== 'test') throw new IllegalTransition(card.status, action)
|
|
156
|
+
if (payload.green) return { card: advance(card, i, events), events }
|
|
157
|
+
const target = bounceTarget(card, i)
|
|
158
|
+
// test and land bounces share one counter (land_attempts) and one cap, so a
|
|
159
|
+
// card that never goes green cannot loop forever
|
|
160
|
+
const attempts = (card.land_attempts ?? 0) + 1
|
|
161
|
+
const max = Math.max(1, parseInt((process.env.LEG_MAX_LAND_ATTEMPTS || process.env.BATON_MAX_LAND_ATTEMPTS) || '3', 10) || 3)
|
|
162
|
+
if (!target || attempts >= max) {
|
|
163
|
+
events.push(ev('failed', target ? `test red after ${attempts} attempt(s)` : 'test red and no agent station to bounce to', payload.reason))
|
|
164
|
+
return { card: { ...card, status: 'failed', failure: 'test', land_attempts: attempts }, events }
|
|
165
|
+
}
|
|
166
|
+
events.push(ev('bounced', `test red (attempt ${attempts}) → ${target.name}`, payload.reason))
|
|
167
|
+
// the next build leg starts from the bounce bundle (the failure is in its Open findings)
|
|
168
|
+
return { card: { ...card, station: target.name, leg: 0, status: 'queued', land_attempts: attempts, bounce_reason: payload.reason ?? 'test red', resume_from_bundle: true }, events }
|
|
169
|
+
}
|
|
170
|
+
case 'land_result': {
|
|
171
|
+
// payload: { landed, bounced, reason } — phase 7 fills the real queue
|
|
172
|
+
assertStatus(card, action, ['running'])
|
|
173
|
+
if (!s || s.kind !== 'land') throw new IllegalTransition(card.status, action)
|
|
174
|
+
if (payload.landed) {
|
|
175
|
+
events.push(ev('landed', payload.summary ?? 'landed on trunk', payload.body))
|
|
176
|
+
return { card: advance(card, i, events), events }
|
|
177
|
+
}
|
|
178
|
+
if (payload.pr) {
|
|
179
|
+
// pr land mode: the human merges; the card waits at the land station
|
|
180
|
+
events.push(ev('approval_needed', payload.summary ?? `pull request opened: ${payload.url ?? ''}`))
|
|
181
|
+
return { card: { ...card, status: 'waiting_human', pr_url: payload.url ?? null }, events }
|
|
182
|
+
}
|
|
183
|
+
const attempts = (card.land_attempts ?? 0) + 1
|
|
184
|
+
const maxAttempts = Math.max(1, parseInt((process.env.LEG_MAX_LAND_ATTEMPTS || process.env.BATON_MAX_LAND_ATTEMPTS) || '3', 10) || 3)
|
|
185
|
+
const target = payload.bounced ? bounceTarget(card, i) : null
|
|
186
|
+
if (target && attempts < maxAttempts) {
|
|
187
|
+
events.push(ev('bounced', `land bounced (attempt ${attempts}) → ${target.name}`, payload.reason))
|
|
188
|
+
return { card: { ...card, station: target.name, leg: 0, status: 'queued', land_attempts: attempts, bounce_reason: payload.reason ?? 'land bounced', resume_from_bundle: true }, events }
|
|
189
|
+
}
|
|
190
|
+
events.push(ev('failed', payload.bounced && !target ? 'land bounced and no agent station to bounce to' : `land failed after ${attempts} attempt(s)`, payload.reason))
|
|
191
|
+
return { card: { ...card, status: 'failed', land_attempts: attempts, failure: 'land' }, events }
|
|
192
|
+
}
|
|
193
|
+
// ---- human actions ----
|
|
194
|
+
case 'pause': {
|
|
195
|
+
assertStatus(card, action, ['running'])
|
|
196
|
+
events.push(ev('paused', 'paused by human'))
|
|
197
|
+
return { card: { ...card, status: 'paused' }, events }
|
|
198
|
+
}
|
|
199
|
+
case 'resume': {
|
|
200
|
+
assertStatus(card, action, ['paused'])
|
|
201
|
+
events.push(ev('resumed', `resumed at ${card.station} leg ${card.leg}`))
|
|
202
|
+
return { card: { ...card, status: 'queued', resume_from_bundle: true }, events }
|
|
203
|
+
}
|
|
204
|
+
case 'kill': {
|
|
205
|
+
assertStatus(card, action, NON_TERMINAL)
|
|
206
|
+
events.push(ev('killed', 'killed by human'))
|
|
207
|
+
return { card: { ...card, status: 'killed', kill_requested: true }, events }
|
|
208
|
+
}
|
|
209
|
+
case 'reassign': {
|
|
210
|
+
assertStatus(card, action, NON_TERMINAL)
|
|
211
|
+
if (!s || s.kind !== 'agent') throw new IllegalTransition(card.status, action)
|
|
212
|
+
if (!payload.adapter) throw new Error('reassign needs an adapter')
|
|
213
|
+
const entry = { adapter: payload.adapter }
|
|
214
|
+
if (payload.mode) entry.mode = payload.mode
|
|
215
|
+
const chain = [...s.chain.slice(0, card.leg), entry, ...s.chain.slice(card.leg + 1)]
|
|
216
|
+
const pipeline = card.pipeline.map((st) => (st.name === s.name ? { ...st, chain } : st))
|
|
217
|
+
events.push(ev('reassigned', `leg ${card.leg} at ${s.name} reassigned to ${payload.adapter}${payload.mode ? ` (${payload.mode})` : ''}`))
|
|
218
|
+
return { card: { ...card, pipeline, status: 'queued', kill_requested: card.status === 'running' }, events }
|
|
219
|
+
}
|
|
220
|
+
case 'handoff_now': {
|
|
221
|
+
assertStatus(card, action, ['running'])
|
|
222
|
+
if (!s || s.kind !== 'agent') throw new IllegalTransition(card.status, action)
|
|
223
|
+
const next = s.chain[card.leg + 1]
|
|
224
|
+
if (!next) throw new IllegalTransition(card.status, action, 'handing_off')
|
|
225
|
+
events.push(ev('status', 'handoff requested by human'))
|
|
226
|
+
return { card: { ...card, status: 'handing_off', handoff_outcome: 'incomplete', next_leg: card.leg + 1, kill_requested: true }, events }
|
|
227
|
+
}
|
|
228
|
+
case 'approve': {
|
|
229
|
+
assertStatus(card, action, ['needs_approval', 'waiting_human'])
|
|
230
|
+
if (card.status === 'needs_approval') {
|
|
231
|
+
events.push(ev('approved', `leg ${card.leg} at ${card.station} approved`))
|
|
232
|
+
return { card: { ...card, status: 'queued' }, events }
|
|
233
|
+
}
|
|
234
|
+
events.push(ev('approved', `human station ${card.station} approved`))
|
|
235
|
+
return { card: advance(card, i, events), events }
|
|
236
|
+
}
|
|
237
|
+
case 'rerun': {
|
|
238
|
+
assertStatus(card, action, TERMINAL)
|
|
239
|
+
const first = card.pipeline[0]
|
|
240
|
+
events.push(ev('status', `rerun from ${first.name} leg 0`))
|
|
241
|
+
return { card: { ...card, station: first.name, leg: 0, status: first.kind === 'human' ? 'waiting_human' : 'queued', land_attempts: 0, bounce_reason: null, failure: null, kill_requested: false }, events }
|
|
242
|
+
}
|
|
243
|
+
default:
|
|
244
|
+
throw new Error(`unknown action "${action}"`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const HUMAN_ACTIONS = ['pause', 'resume', 'kill', 'reassign', 'handoff_now', 'approve', 'rerun']
|
|
249
|
+
|
|
250
|
+
// Which human buttons make sense for a card right now (the board asks this).
|
|
251
|
+
export function availableActions(card) {
|
|
252
|
+
const out = []
|
|
253
|
+
const st = card.status
|
|
254
|
+
const { s } = station(card)
|
|
255
|
+
if (st === 'running') out.push('pause')
|
|
256
|
+
// the transition refuses handoff_now without a next chain entry, so the board must not offer it
|
|
257
|
+
if (st === 'running' && s?.kind === 'agent' && s.chain?.[card.leg + 1]) out.push('handoff_now')
|
|
258
|
+
if (st === 'paused') out.push('resume')
|
|
259
|
+
if (st === 'needs_approval' || st === 'waiting_human') out.push('approve')
|
|
260
|
+
if (NON_TERMINAL.includes(st)) { out.push('kill'); if (s?.kind === 'agent') out.push('reassign') }
|
|
261
|
+
if (TERMINAL.includes(st)) out.push('rerun')
|
|
262
|
+
if (st === 'backlog') out.push('enqueue')
|
|
263
|
+
return out
|
|
264
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// commands — run a repository command (tests) as argv, never through a shell.
|
|
2
|
+
// `npm`/`npx`/`node` resolve to node + their JS entry so the Windows .cmd shim
|
|
3
|
+
// is never needed (LESSONS 07-11).
|
|
4
|
+
import { spawnSync, spawn } from 'node:child_process'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { resolveNpmCliEntry } from './adapters/resolve.mjs'
|
|
8
|
+
import { scrub } from './runner.mjs'
|
|
9
|
+
|
|
10
|
+
function tokenize(cmd) {
|
|
11
|
+
return cmd.match(/(?:[^\s"]+|"[^"]*")+/g)?.map((t) => t.replace(/^"|"$/g, '')) ?? []
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveCommand(cmd) {
|
|
15
|
+
const tokens = tokenize(cmd)
|
|
16
|
+
if (!tokens.length) throw new Error('empty test command')
|
|
17
|
+
const [bin, ...rest] = tokens
|
|
18
|
+
if (bin === 'node') return { bin: process.execPath, args: rest }
|
|
19
|
+
if (bin === 'npm' || bin === 'npx') {
|
|
20
|
+
const entry = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', `${bin}-cli.js`)
|
|
21
|
+
const found = existsSync(entry) ? entry : resolveNpmCliEntry('npm', bin)
|
|
22
|
+
if (!found) throw new Error(`cannot resolve ${bin}'s JS entry`)
|
|
23
|
+
return { bin: process.execPath, args: [found, ...rest] }
|
|
24
|
+
}
|
|
25
|
+
return { bin, args: rest }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A nested `node --test` inherits NODE_TEST_CONTEXT from a parent test runner
|
|
29
|
+
// and then reports to that parent instead of exiting red; the repo's tests
|
|
30
|
+
// must run as a plain process.
|
|
31
|
+
function commandEnv() {
|
|
32
|
+
const env = { ...process.env, MSYS_NO_PATHCONV: '1', CI: '1' }
|
|
33
|
+
for (const k of Object.keys(env)) if (k.startsWith('NODE_TEST')) delete env[k]
|
|
34
|
+
return env
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function runCommand(cmd, cwd, { timeoutMs = 600000, tailLines = 40 } = {}) {
|
|
38
|
+
const { bin, args } = resolveCommand(cmd)
|
|
39
|
+
const r = spawnSync(bin, args, { cwd, windowsHide: true, encoding: 'utf8', timeout: timeoutMs, env: commandEnv() })
|
|
40
|
+
const tail = scrub(`${r.stdout ?? ''}\n${r.stderr ?? ''}`).trim().split('\n').slice(-tailLines).join('\n')
|
|
41
|
+
return { green: r.status === 0, status: r.status, timedOut: r.error?.code === 'ETIMEDOUT', tail, command: `${bin} ${args.join(' ')}` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The same result without blocking the event loop: the board server lands a
|
|
45
|
+
// terminal's branch in-process, and a long test run must not freeze the board.
|
|
46
|
+
export function runCommandAsync(cmd, cwd, { timeoutMs = 600000, tailLines = 40 } = {}) {
|
|
47
|
+
const { bin, args } = resolveCommand(cmd)
|
|
48
|
+
return new Promise((resolvePromise) => {
|
|
49
|
+
let out = ''
|
|
50
|
+
let timedOut = false
|
|
51
|
+
let done = false
|
|
52
|
+
const child = spawn(bin, args, { cwd, windowsHide: true, env: commandEnv() })
|
|
53
|
+
const keep = (d) => { out += d; if (out.length > 2e6) out = out.slice(-1e6) }
|
|
54
|
+
child.stdout.on('data', keep)
|
|
55
|
+
child.stderr.on('data', keep)
|
|
56
|
+
// On timeout, kill the whole process TREE: child.kill() ends only the npm
|
|
57
|
+
// node process, and a grandchild (a watcher, a dev server) keeps the stdio
|
|
58
|
+
// pipes open so 'close' never fires and the landing hangs in 'landing'
|
|
59
|
+
// forever, wedging the repo's merge queue. taskkill /T /F takes the tree.
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
timedOut = true
|
|
62
|
+
if (process.platform === 'win32' && child.pid) { try { spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }) } catch {} }
|
|
63
|
+
else { try { child.kill('SIGKILL') } catch {} }
|
|
64
|
+
}, timeoutMs)
|
|
65
|
+
const finish = (status, err) => {
|
|
66
|
+
if (done) return
|
|
67
|
+
done = true
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
const tail = scrub(`${out}${err ? `\n${err.message}` : ''}`).trim().split('\n').slice(-tailLines).join('\n')
|
|
70
|
+
resolvePromise({ green: status === 0 && !timedOut, status, timedOut, tail, command: `${bin} ${args.join(' ')}` })
|
|
71
|
+
}
|
|
72
|
+
child.on('error', (err) => finish(null, err))
|
|
73
|
+
// resolve on 'exit' (fires when the process ends) as well as 'close' (waits
|
|
74
|
+
// for every inherited pipe to close, which a detached grandchild can hold
|
|
75
|
+
// open indefinitely); finish is idempotent via `done`
|
|
76
|
+
child.on('exit', (code) => finish(code))
|
|
77
|
+
child.on('close', (code) => finish(code))
|
|
78
|
+
})
|
|
79
|
+
}
|
package/src/contract.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// contract — the per-leg prompt. Every CLI gets the same contract file at
|
|
2
|
+
// .baton/CONTRACT.md in the worktree (CLI-agnostic completion: write
|
|
3
|
+
// .baton/DONE when finished). Leg 1 prompt = the contract; a later leg or a
|
|
4
|
+
// resume = the handoff bundle's resume text + the contract.
|
|
5
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const TEMPLATES = {
|
|
9
|
+
plan: () => import('./stations/plan.mjs'),
|
|
10
|
+
build: () => import('./stations/build.mjs'),
|
|
11
|
+
review: () => import('./stations/review.mjs'),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function stationTemplate(name = 'build') {
|
|
15
|
+
const load = TEMPLATES[name] ?? TEMPLATES.build
|
|
16
|
+
return (await load()).default
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function renderContract({ card, station, leg, entry, worktree, resumed = false, bounceReason = null }) {
|
|
20
|
+
const t = await stationTemplate(station.prompt ?? station.name)
|
|
21
|
+
const lines = [
|
|
22
|
+
`# Leg contract, card ${card.card_id}, station ${station.name}, leg ${leg} (${entry?.adapter ?? 'agent'})`,
|
|
23
|
+
'',
|
|
24
|
+
'## Task',
|
|
25
|
+
'',
|
|
26
|
+
card.task,
|
|
27
|
+
'',
|
|
28
|
+
'## Station goal',
|
|
29
|
+
'',
|
|
30
|
+
t.goal,
|
|
31
|
+
'',
|
|
32
|
+
...t.deliverables.map((d) => `- ${d}`),
|
|
33
|
+
'',
|
|
34
|
+
]
|
|
35
|
+
if (bounceReason) {
|
|
36
|
+
lines.push('## Why this card came back', '', bounceReason, '')
|
|
37
|
+
}
|
|
38
|
+
if (resumed) {
|
|
39
|
+
lines.push('## Continuation', '',
|
|
40
|
+
'A previous agent already worked on this card in this same directory. Its handoff resume is above this contract; .leg/PROGRESS.md holds its notes. Continue from there; do not start over.', '')
|
|
41
|
+
}
|
|
42
|
+
lines.push(
|
|
43
|
+
'## Rules',
|
|
44
|
+
'',
|
|
45
|
+
`- Work only inside this directory: ${worktree}. It is a git worktree on its own branch; commit as you go or leave changes uncommitted, both are fine.`,
|
|
46
|
+
'- Keep .leg/PROGRESS.md updated as you go: one line per meaningful step, newest last. It is how the next agent (or a human) picks up if you stop early.',
|
|
47
|
+
'- Do not push, do not create remotes, do not open pull requests, do not change git config.',
|
|
48
|
+
'- Do not touch anything under .leg/ except PROGRESS.md and DONE.',
|
|
49
|
+
'- No interactive prompt will be answered; if you need a permission you do not have, write what you need to .leg/PROGRESS.md and stop.',
|
|
50
|
+
'',
|
|
51
|
+
'## Finish',
|
|
52
|
+
'',
|
|
53
|
+
'When the task is finished and verified, write the file .leg/DONE containing one line that summarizes what you did. Without that file Leg treats the run as unfinished and hands it to the next agent.',
|
|
54
|
+
'',
|
|
55
|
+
)
|
|
56
|
+
return lines.join('\n')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function writeContract(worktree, text) {
|
|
60
|
+
const dir = join(worktree, '.leg')
|
|
61
|
+
mkdirSync(dir, { recursive: true })
|
|
62
|
+
const path = join(dir, 'CONTRACT.md')
|
|
63
|
+
writeFileSync(path, text)
|
|
64
|
+
return path
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function legPrompt({ contractText, resumeText = null }) {
|
|
68
|
+
const head = 'You are working on a Leg card. The contract below is also saved at .leg/CONTRACT.md.'
|
|
69
|
+
if (resumeText) {
|
|
70
|
+
return `${head}\n\n=== HANDOFF RESUME (from the previous agent) ===\n\n${resumeText.trim()}\n\n=== CONTRACT ===\n\n${contractText}`
|
|
71
|
+
}
|
|
72
|
+
return `${head}\n\n${contractText}`
|
|
73
|
+
}
|