@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/pipeline.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Pipeline = ordered stations a card moves through. Board columns derive from
|
|
2
|
+
// it (phase 6). Validation happens at card creation so a forbidden mode or a
|
|
3
|
+
// misplaced land station never reaches a worktree.
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { PRESETS, PRESET_NAMES } from './presets.mjs'
|
|
6
|
+
import { get as getAdapter, names as adapterNames } from './adapters/index.mjs'
|
|
7
|
+
|
|
8
|
+
export const KINDS = ['agent', 'human', 'test', 'land']
|
|
9
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/
|
|
10
|
+
|
|
11
|
+
// chain entry: { adapter, mode?, maxTurns?, approve?, network?, fakeMode?, model? }
|
|
12
|
+
export function normalizeChainEntry(e) {
|
|
13
|
+
if (!e || typeof e !== 'object' || typeof e.adapter !== 'string') throw new Error('chain entry needs an adapter name')
|
|
14
|
+
const out = { adapter: e.adapter }
|
|
15
|
+
if (e.mode !== undefined && e.mode !== null) out.mode = String(e.mode)
|
|
16
|
+
const mt = e.maxTurns ?? e.max_turns
|
|
17
|
+
if (mt !== undefined && mt !== null) {
|
|
18
|
+
out.maxTurns = Number(mt)
|
|
19
|
+
if (!Number.isInteger(out.maxTurns) || out.maxTurns < 1) throw new Error(`chain entry ${e.adapter}: maxTurns must be a positive integer`)
|
|
20
|
+
}
|
|
21
|
+
if (e.approve) out.approve = true
|
|
22
|
+
if (e.network) out.network = true
|
|
23
|
+
if (e.fakeMode !== undefined) out.fakeMode = String(e.fakeMode)
|
|
24
|
+
for (const k of ['fakeFixture', 'fakeTarget', 'fakeContent']) if (e[k] !== undefined && e[k] !== null) out[k] = String(e[k])
|
|
25
|
+
if (e.model !== undefined && e.model !== null) out.model = String(e.model)
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// "claude,codex" | [{adapter}] → normalized entries
|
|
30
|
+
export function parseChain(raw) {
|
|
31
|
+
if (Array.isArray(raw)) return raw.map(normalizeChainEntry)
|
|
32
|
+
if (typeof raw === 'string') {
|
|
33
|
+
const s = raw.trim()
|
|
34
|
+
if (s.startsWith('[')) return JSON.parse(s).map(normalizeChainEntry)
|
|
35
|
+
return s.split(',').map((x) => x.trim()).filter(Boolean).map((adapter) => ({ adapter }))
|
|
36
|
+
}
|
|
37
|
+
throw new Error('chain must be a comma list or a JSON array')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// preset name | path to a JSON file | array → stations with the default chain applied
|
|
41
|
+
export function buildPipeline({ preset, file, stations, chain }) {
|
|
42
|
+
let base
|
|
43
|
+
if (Array.isArray(stations)) base = stations
|
|
44
|
+
else if (file) base = JSON.parse(readFileSync(file, 'utf8'))
|
|
45
|
+
else base = PRESETS[preset ?? 'build']
|
|
46
|
+
if (!base) throw new Error(`unknown pipeline preset "${preset}" (known: ${PRESET_NAMES.join(', ')})`)
|
|
47
|
+
if (!Array.isArray(base)) throw new Error('pipeline must be an array of stations')
|
|
48
|
+
return base.map((s) => {
|
|
49
|
+
const st = { name: s.name, kind: s.kind }
|
|
50
|
+
if (s.prompt) st.prompt = s.prompt
|
|
51
|
+
if (s.kind === 'agent') st.chain = (s.chain ?? chain ?? []).map(normalizeChainEntry)
|
|
52
|
+
if (s.command) st.command = s.command
|
|
53
|
+
return st
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Registered adapters with their allowed modes, for validation.
|
|
58
|
+
export async function loadAdapterModes(names = adapterNames()) {
|
|
59
|
+
const out = {}
|
|
60
|
+
for (const n of names) {
|
|
61
|
+
const a = await getAdapter(n)
|
|
62
|
+
out[n] = { allowed: a.modes.allowed, default: a.modes.default }
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Throws a named error on: empty pipeline, duplicate names, unknown kinds,
|
|
68
|
+
// agent station without a chain, unregistered adapter, forbidden mode, land
|
|
69
|
+
// station not last or more than one.
|
|
70
|
+
export function validatePipeline(pipeline, adapterModes) {
|
|
71
|
+
if (!Array.isArray(pipeline) || pipeline.length === 0) throw new Error('pipeline is empty')
|
|
72
|
+
const seen = new Set()
|
|
73
|
+
pipeline.forEach((s, i) => {
|
|
74
|
+
if (!s || typeof s.name !== 'string' || !NAME_RE.test(s.name)) throw new Error(`station ${i}: invalid name "${s?.name}"`)
|
|
75
|
+
if (seen.has(s.name)) throw new Error(`duplicate station name "${s.name}"`)
|
|
76
|
+
seen.add(s.name)
|
|
77
|
+
if (!KINDS.includes(s.kind)) throw new Error(`station "${s.name}": unknown kind "${s.kind}" (allowed: ${KINDS.join(', ')})`)
|
|
78
|
+
if (s.kind === 'agent') {
|
|
79
|
+
if (!Array.isArray(s.chain) || s.chain.length === 0) throw new Error(`station "${s.name}": agent station needs a non-empty chain`)
|
|
80
|
+
for (const e of s.chain) {
|
|
81
|
+
const modes = adapterModes[e.adapter]
|
|
82
|
+
if (!modes) throw new Error(`station "${s.name}": unknown adapter "${e.adapter}" (registered: ${Object.keys(adapterModes).join(', ')})`)
|
|
83
|
+
if (e.mode !== undefined && !modes.allowed.includes(e.mode)) {
|
|
84
|
+
throw new Error(`forbidden mode "${e.mode}" for ${e.adapter} (allowed: ${modes.allowed.join(', ')})`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (s.kind === 'land' && i !== pipeline.length - 1) throw new Error(`station "${s.name}": a land station must be last`)
|
|
89
|
+
})
|
|
90
|
+
if (pipeline.filter((s) => s.kind === 'land').length > 1) throw new Error('at most one land station')
|
|
91
|
+
return pipeline
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function stationIndex(pipeline, name) {
|
|
95
|
+
return pipeline.findIndex((s) => s.name === name)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function columns(pipeline) {
|
|
99
|
+
return ['backlog', ...pipeline.map((s) => s.name), 'done']
|
|
100
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Machine-wide defaults for interactive Baton terminals. A new terminal takes
|
|
2
|
+
// a copy of these preferences when it starts; later edits do not silently
|
|
3
|
+
// change terminals that are already running.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { home } from './store.mjs'
|
|
7
|
+
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
8
|
+
|
|
9
|
+
export const HANDOFF_AGENTS = ['claude', 'codex', 'agy']
|
|
10
|
+
|
|
11
|
+
export function validHandoffOrder(value) {
|
|
12
|
+
return Array.isArray(value) && value.length === HANDOFF_AGENTS.length &&
|
|
13
|
+
new Set(value).size === HANDOFF_AGENTS.length &&
|
|
14
|
+
value.every((agent) => HANDOFF_AGENTS.includes(agent))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeHandoffOrder(value) {
|
|
18
|
+
return validHandoffOrder(value) ? [...value] : [...HANDOFF_AGENTS]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function requireHandoffOrder(value) {
|
|
22
|
+
if (!validHandoffOrder(value)) throw new TypeError('handoff_order must contain claude, codex, and agy exactly once')
|
|
23
|
+
return [...value]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function preferencesFile() { return join(home(), 'preferences.json') }
|
|
27
|
+
|
|
28
|
+
export function readPreferences() {
|
|
29
|
+
const file = preferencesFile()
|
|
30
|
+
if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS] }
|
|
31
|
+
try {
|
|
32
|
+
const value = JSON.parse(readFileSync(file, 'utf8'))
|
|
33
|
+
return { handoff_order: normalizeHandoffOrder(value?.handoff_order) }
|
|
34
|
+
} catch {
|
|
35
|
+
return { handoff_order: [...HANDOFF_AGENTS] }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writePreferences(patch) {
|
|
40
|
+
const order = requireHandoffOrder(patch?.handoff_order)
|
|
41
|
+
mkdirSync(home(), { recursive: true })
|
|
42
|
+
return withFileLock(preferencesFile() + '.lock', () => {
|
|
43
|
+
const next = { ...readPreferences(), handoff_order: order }
|
|
44
|
+
writeJsonAtomic(preferencesFile(), next)
|
|
45
|
+
return next
|
|
46
|
+
})
|
|
47
|
+
}
|
package/src/presets.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Pipeline presets. A station is { name, kind, chain?, prompt? }; kinds are
|
|
2
|
+
// agent | human | test | land. `chain` on an agent station defaults to the
|
|
3
|
+
// card's chain when omitted (src/pipeline.mjs applies it). The v1 limit
|
|
4
|
+
// handoff runs inside any agent station; `factory` is where v2 is headed.
|
|
5
|
+
export const PRESETS = {
|
|
6
|
+
factory: [
|
|
7
|
+
{ name: 'plan', kind: 'agent', prompt: 'plan' },
|
|
8
|
+
{ name: 'build', kind: 'agent', prompt: 'build' },
|
|
9
|
+
{ name: 'review', kind: 'agent', prompt: 'review' },
|
|
10
|
+
{ name: 'test', kind: 'test' },
|
|
11
|
+
{ name: 'land', kind: 'land' },
|
|
12
|
+
],
|
|
13
|
+
build: [
|
|
14
|
+
{ name: 'build', kind: 'agent', prompt: 'build' },
|
|
15
|
+
],
|
|
16
|
+
'build-land': [
|
|
17
|
+
{ name: 'build', kind: 'agent', prompt: 'build' },
|
|
18
|
+
{ name: 'test', kind: 'test' },
|
|
19
|
+
{ name: 'land', kind: 'land' },
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const PRESET_NAMES = Object.keys(PRESETS)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ratelimit — fixed windows for the board's API, in memory (one process, and a
|
|
2
|
+
// restart is a fresh window). Two buckets: requests per identity (a person's
|
|
3
|
+
// name when they are known, else the client address) so one client cannot
|
|
4
|
+
// flood the board, and failed authorizations per address so a token cannot be
|
|
5
|
+
// guessed at speed. Both are deliberately generous: the board itself polls.
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
const WINDOW_MS = 60000
|
|
9
|
+
|
|
10
|
+
export function createLimiter({
|
|
11
|
+
windowMs = WINDOW_MS,
|
|
12
|
+
max = Number((process.env.LEG_RATE_MAX || process.env.BATON_RATE_MAX) || 600),
|
|
13
|
+
maxFailures = Number((process.env.LEG_RATE_MAX_FAILURES || process.env.BATON_RATE_MAX_FAILURES) || 20),
|
|
14
|
+
} = {}) {
|
|
15
|
+
const hits = new Map()
|
|
16
|
+
const fails = new Map()
|
|
17
|
+
const tried = new Map()
|
|
18
|
+
const sweep = (map, now) => { for (const [k, v] of map) if (now - v.start >= windowMs) map.delete(k) }
|
|
19
|
+
const bump = (map, key, limit, now) => {
|
|
20
|
+
if (map.size > 1000) sweep(map, now)
|
|
21
|
+
const cur = map.get(key)
|
|
22
|
+
if (!cur || now - cur.start >= windowMs) { map.set(key, { start: now, n: 1 }); return { ok: true, count: 1, retry_after: 0 } }
|
|
23
|
+
cur.n += 1
|
|
24
|
+
if (cur.n > limit) return { ok: false, count: cur.n, retry_after: Math.max(1, Math.ceil((cur.start + windowMs - now) / 1000)) }
|
|
25
|
+
return { ok: true, count: cur.n, retry_after: 0 }
|
|
26
|
+
}
|
|
27
|
+
// Guessing means trying tokens; a board page retrying the one token it was
|
|
28
|
+
// given (a tab open since `share rotate`) is not, and must not lock the
|
|
29
|
+
// machine it runs on out of its own board. One value counts once a window.
|
|
30
|
+
const guess = (key, token, now) => {
|
|
31
|
+
if (!token) return true
|
|
32
|
+
const h = createHash('sha256').update(String(token)).digest('hex')
|
|
33
|
+
const cur = tried.get(key)
|
|
34
|
+
if (!cur || now - cur.start >= windowMs) { if (tried.size > 1000) sweep(tried, now); tried.set(key, { start: now, seen: new Set([h]) }); return true }
|
|
35
|
+
if (cur.seen.has(h)) return false
|
|
36
|
+
cur.seen.add(h)
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
request: (key, now = Date.now()) => bump(hits, key, max, now),
|
|
41
|
+
failure: (key, token = null, now = Date.now()) => (guess(key, token, now) ? bump(fails, key, maxFailures, now) : { ok: true, count: fails.get(key)?.n ?? 0, retry_after: 0 }),
|
|
42
|
+
// true once this address has spent its failed-token budget for the window
|
|
43
|
+
lockedOut: (key, now = Date.now()) => { const cur = fails.get(key); return Boolean(cur && now - cur.start < windowMs && cur.n > maxFailures) },
|
|
44
|
+
retryAfter: (key, now = Date.now()) => { const cur = fails.get(key); return cur ? Math.max(1, Math.ceil((cur.start + windowMs - now) / 1000)) : 1 },
|
|
45
|
+
reset: () => { hits.clear(); fails.clear(); tried.clear() },
|
|
46
|
+
max,
|
|
47
|
+
maxFailures,
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/redact.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// redact — the one list of secret shapes. `scrub()` rewrites (logs, bundles,
|
|
2
|
+
// launcher output); the ledger's assertNoSecrets refuses (src/ledger.mjs).
|
|
3
|
+
// The source tooling kept two copies on purpose; Baton keeps one here.
|
|
4
|
+
// Values the launcher's own process holds for the well-known key variables
|
|
5
|
+
// are read once at startup and never printed.
|
|
6
|
+
// Every prefix shape starts at a token boundary: the `sk-` inside
|
|
7
|
+
// "task-management-system" is a word, not a key, and the ledger refuses a card
|
|
8
|
+
// whose task matches.
|
|
9
|
+
const PATTERNS = [
|
|
10
|
+
['api key (sk-)', /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{8,}/g],
|
|
11
|
+
['Anthropic key (sk-ant-)', /(?<![A-Za-z0-9_-])sk-ant-[A-Za-z0-9_-]{8,}/g],
|
|
12
|
+
['DashClaw key (oc_live_)', /(?<![A-Za-z0-9_-])oc_live_[a-f0-9]\w*/g],
|
|
13
|
+
['bearer token', /Bearer\s+[A-Za-z0-9._-]{16,}/g],
|
|
14
|
+
['GitHub token (ghp_)', /(?<![A-Za-z0-9_-])ghp_[A-Za-z0-9]{20,}/g],
|
|
15
|
+
['GitHub server token (ghs_)', /(?<![A-Za-z0-9_-])ghs_[A-Za-z0-9]{20,}/g],
|
|
16
|
+
['GitHub fine-grained token (github_pat_)', /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/g],
|
|
17
|
+
['AWS key (AKIA)', /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{12,}/g],
|
|
18
|
+
['Slack token (xox)', /(?<![A-Za-z0-9_-])xox[bp]-\S*/g],
|
|
19
|
+
['key=value secret', /api[_-]?key\s*[=:]\s*\S+/gi],
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
export const SECRET_RES = PATTERNS.map(([, re]) => re)
|
|
23
|
+
// non-global copies for `.test()` (a /g regex carries lastIndex state)
|
|
24
|
+
export const SECRET_PATTERNS = PATTERNS.map(([name, re]) => [name, new RegExp(re.source, re.flags.replace('g', ''))])
|
|
25
|
+
|
|
26
|
+
const ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'DASHCLAW_API_KEY', 'BATON_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN']
|
|
27
|
+
let envValues = null
|
|
28
|
+
function heldValues() {
|
|
29
|
+
if (envValues) return envValues
|
|
30
|
+
envValues = ENV_KEYS.map((k) => process.env[k]).filter((v) => typeof v === 'string' && v.length >= 8)
|
|
31
|
+
return envValues
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function scrub(s) {
|
|
35
|
+
let out = String(s)
|
|
36
|
+
for (const re of SECRET_RES) { re.lastIndex = 0; out = out.replace(re, '[REDACTED]') }
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function redact(line) {
|
|
41
|
+
let out = scrub(line)
|
|
42
|
+
for (const v of heldValues()) out = out.split(v).join('[REDACTED]')
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// tests plant a value and need the cache dropped
|
|
47
|
+
export function _resetHeldValues() { envValues = null }
|
package/src/resume.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// resume — the pointer that cannot describe a picture that is no longer true.
|
|
2
|
+
//
|
|
3
|
+
// `.baton/RESUME.md` is the file humans and other agents open by habit. It used
|
|
4
|
+
// to be an unowned convenience copy: written once per hand-off, never touched
|
|
5
|
+
// again, with no stamp and no expiry, so a normally exited terminal left hours
|
|
6
|
+
// old text sitting there looking live.
|
|
7
|
+
//
|
|
8
|
+
// Two rules fix that, and they are the whole module:
|
|
9
|
+
// 1. Every resume file Baton writes carries a stamp of the git state and the
|
|
10
|
+
// live terminals it was written against (an HTML comment, invisible in
|
|
11
|
+
// rendered markdown).
|
|
12
|
+
// 2. Freshness is never remembered — it is recomputed from git at READ time.
|
|
13
|
+
// A file cannot lie about HEAD to a reader who re-asks git.
|
|
14
|
+
// Baton owns the file: it rewrites it when a session ends and when the board
|
|
15
|
+
// starts, so nothing is left describing a terminal that is gone.
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { spawnSync } from 'node:child_process'
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
import { dirname, join, resolve } from 'node:path'
|
|
20
|
+
import { canonPath } from './fsx.mjs'
|
|
21
|
+
import { isActive, listSessions, reapLost, workRoot } from './sessions.mjs'
|
|
22
|
+
|
|
23
|
+
export const STAMP_PREFIX = '<!-- leg-resume '
|
|
24
|
+
export const LEGACY_STAMP_PREFIX = '<!-- baton-resume '
|
|
25
|
+
const STAMP_SUFFIX = ' -->'
|
|
26
|
+
const STAMP_VERSION = 1
|
|
27
|
+
// Baton's own directories dirty the tree on every write; a reader must not see
|
|
28
|
+
// Baton's bookkeeping as the human's work moving on.
|
|
29
|
+
const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
|
|
30
|
+
const MAX_REASONS = 5
|
|
31
|
+
|
|
32
|
+
// fresh and its idle twin are the only states a script should keep going on.
|
|
33
|
+
export const EXIT = { fresh: 0, stale: 1, unstamped: 1, missing: 3 }
|
|
34
|
+
|
|
35
|
+
function git(cwd, args) {
|
|
36
|
+
const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', timeout: 5000, env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
37
|
+
return r.status === 0 ? r.stdout.trim() : ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A count and a sorted hash, never the names: a shared board must not leak
|
|
41
|
+
// what the human is working on, and a list would make the stamp unbounded.
|
|
42
|
+
export function dirtyFingerprint(cwd) {
|
|
43
|
+
const lines = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean)
|
|
44
|
+
.map((l) => l.slice(3).replace(/^"|"$/g, ''))
|
|
45
|
+
.filter((f) => !LEG_DIRS.test(f))
|
|
46
|
+
.sort()
|
|
47
|
+
if (!lines.length) return { count: 0, hash: null }
|
|
48
|
+
return { count: lines.length, hash: createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 12) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// What git says right now. Every field is null outside a repository, which
|
|
52
|
+
// makes a pointer in a non-repo directory permanently undriftable rather than
|
|
53
|
+
// permanently stale.
|
|
54
|
+
export function gitState(cwd) {
|
|
55
|
+
const head = git(cwd, ['rev-parse', 'HEAD']) || null
|
|
56
|
+
const branch = head ? (git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']) || null) : null
|
|
57
|
+
return { head, short: head ? head.slice(0, 7) : null, branch, dirty: dirtyFingerprint(cwd) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// How many commits landed between the stamp's HEAD and this one. null when the
|
|
61
|
+
// two are not on one line of history (a rebase, a reset, a different clone).
|
|
62
|
+
function commitsSince(cwd, then) {
|
|
63
|
+
if (!then) return null
|
|
64
|
+
const n = git(cwd, ['rev-list', '--count', `${then}..HEAD`])
|
|
65
|
+
return /^\d+$/.test(n) ? Number(n) : null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resumeFile(cwd) {
|
|
69
|
+
const leg = join(cwd, '.leg', 'RESUME.md')
|
|
70
|
+
const baton = join(cwd, '.baton', 'RESUME.md')
|
|
71
|
+
if (existsSync(leg)) return leg
|
|
72
|
+
if (existsSync(baton)) return baton
|
|
73
|
+
if (existsSync(join(cwd, '.baton')) && !existsSync(join(cwd, '.leg'))) return baton
|
|
74
|
+
return leg
|
|
75
|
+
}
|
|
76
|
+
export function perSessionFile(cwd, id) {
|
|
77
|
+
const leg = join(cwd, '.leg', `RESUME-${id}.md`)
|
|
78
|
+
const baton = join(cwd, '.baton', `RESUME-${id}.md`)
|
|
79
|
+
if (existsSync(leg)) return leg
|
|
80
|
+
if (existsSync(baton)) return baton
|
|
81
|
+
if (existsSync(join(cwd, '.baton')) && !existsSync(join(cwd, '.leg'))) return baton
|
|
82
|
+
return leg
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// An agent started deeper in the tree still finds its checkout's pointer.
|
|
86
|
+
export function findResume(startDir) {
|
|
87
|
+
// resolve(), not realPath(). The walk only needs an absolute path, and
|
|
88
|
+
// realPath() also rewrites the spelling: on a Windows host whose temp
|
|
89
|
+
// directory is reached by an 8.3 short name it returns C:\Users\runneradmin
|
|
90
|
+
// for a caller who said C:\Users\RUNNER~1, so the root handed back names a
|
|
91
|
+
// path the caller never used. Two spellings of one checkout are reconciled by
|
|
92
|
+
// canonPath() at the points that compare them, not by quietly renaming the
|
|
93
|
+
// directory the caller asked about.
|
|
94
|
+
let dir = resolve(startDir)
|
|
95
|
+
for (;;) {
|
|
96
|
+
const file = resumeFile(dir)
|
|
97
|
+
if (existsSync(file)) return { root: dir, file }
|
|
98
|
+
const up = dirname(dir)
|
|
99
|
+
if (up === dir) return null
|
|
100
|
+
dir = up
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- the stamp ----
|
|
105
|
+
|
|
106
|
+
export function renderStamp(stamp) { return STAMP_PREFIX + JSON.stringify(stamp) + STAMP_SUFFIX }
|
|
107
|
+
|
|
108
|
+
export function readStamp(text) {
|
|
109
|
+
const line = String(text ?? '').split('\n', 1)[0].trim()
|
|
110
|
+
let pfx = null;
|
|
111
|
+
if (line.startsWith(STAMP_PREFIX)) pfx = STAMP_PREFIX;
|
|
112
|
+
else if (line.startsWith(LEGACY_STAMP_PREFIX)) pfx = LEGACY_STAMP_PREFIX;
|
|
113
|
+
if (!pfx || !line.endsWith(STAMP_SUFFIX)) return null;
|
|
114
|
+
try {
|
|
115
|
+
const stamp = JSON.parse(line.slice(pfx.length, -STAMP_SUFFIX.length))
|
|
116
|
+
return stamp && typeof stamp === 'object' ? stamp : null
|
|
117
|
+
} catch { return null }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The text without its stamp: what a human reads and what `baton resume` prints.
|
|
121
|
+
export function bodyOf(text) {
|
|
122
|
+
const s = String(text ?? '')
|
|
123
|
+
return readStamp(s) ? s.slice(s.indexOf('\n') + 1).replace(/^\n+/, '') : s
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Stamp a body, replacing any stamp it already carries so re-writes never stack.
|
|
127
|
+
export function stampBody(stamp, body) { return `${renderStamp(stamp)}\n\n${bodyOf(body).replace(/^\n+/, '')}` }
|
|
128
|
+
|
|
129
|
+
// The live terminals in one checkout, oldest first. This is the set a pointer
|
|
130
|
+
// is written against: when it changes, the pointer is describing a different
|
|
131
|
+
// room than the one the reader is standing in.
|
|
132
|
+
export function liveIn(root, sessions = listSessions()) {
|
|
133
|
+
const key = canonPath(root)
|
|
134
|
+
return sessions.filter(isActive).filter((s) => { const r = workRoot(s); return r && canonPath(r) === key })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function makeStamp({ kind, session, root, why, bundle, live }) {
|
|
138
|
+
const g = gitState(root)
|
|
139
|
+
return {
|
|
140
|
+
v: STAMP_VERSION, kind,
|
|
141
|
+
session: session?.session_id ?? null,
|
|
142
|
+
agent: session?.agent ?? null,
|
|
143
|
+
account: session?.account ?? null,
|
|
144
|
+
head: g.head, branch: g.branch, dirty: g.dirty,
|
|
145
|
+
live: live.map((s) => ({ id: s.session_id, agent: s.agent })),
|
|
146
|
+
bundle: bundle?.id ?? null,
|
|
147
|
+
why: why ?? null,
|
|
148
|
+
written_at: new Date().toISOString(),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- writing the pointer ----
|
|
153
|
+
|
|
154
|
+
function note(session, others) {
|
|
155
|
+
const mine = `This file describes Leg terminal ${session.session_id} (${session.agent}); its own copy is .leg/RESUME-${session.session_id}.md.`
|
|
156
|
+
if (!others.length) return mine
|
|
157
|
+
const rest = others.map((o) => `${o.session_id} (${o.agent}), whose own hand-off would be .leg/RESUME-${o.session_id}.md`).join('; ')
|
|
158
|
+
return `${mine}\nAlso live in this checkout: ${rest}. RESUME.md describes only the terminal named above.`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The hand-off pointer: one stamped body in .baton/RESUME-<session>.md (the
|
|
162
|
+
// file the next agent's prompt names) and the same text in RESUME.md (the file
|
|
163
|
+
// everyone opens). Returns the text written.
|
|
164
|
+
export function writeHandoffPointer(session, body, { bundle = null, why = null } = {}) {
|
|
165
|
+
const root = workRoot(session)
|
|
166
|
+
if (!root) return null
|
|
167
|
+
const dir = existsSync(join(root, '.baton')) && !existsSync(join(root, '.leg')) ? join(root, '.baton') : join(root, '.leg')
|
|
168
|
+
mkdirSync(dir, { recursive: true })
|
|
169
|
+
const live = liveIn(root)
|
|
170
|
+
const others = live.filter((s) => s.session_id !== session.session_id)
|
|
171
|
+
const stamp = makeStamp({ kind: 'handoff', session, root, why, bundle, live: live.length ? live : [session] })
|
|
172
|
+
const text = stampBody(stamp, `${note(session, others)}\n\n${bodyOf(body)}`)
|
|
173
|
+
writeFileSync(perSessionFile(root, session.session_id), text)
|
|
174
|
+
try { writeFileSync(resumeFile(root), text) } catch { /* a read-only checkout still gets the per-session file */ }
|
|
175
|
+
return text
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The newest hand-off this checkout has seen, for the "nothing in flight"
|
|
179
|
+
// pointer to name. Sessions outlive their terminals, so this survives the one
|
|
180
|
+
// that wrote the hand-off ending.
|
|
181
|
+
export function lastHandoffIn(root, sessions = listSessions()) {
|
|
182
|
+
const key = canonPath(root)
|
|
183
|
+
const when = (s) => s.handoff?.at ?? s.bundle?.updated_at ?? s.ended_at ?? s.updated_at ?? ''
|
|
184
|
+
const rows = sessions
|
|
185
|
+
.filter((s) => { const r = workRoot(s); return r && canonPath(r) === key })
|
|
186
|
+
.filter((s) => s.lineage?.to || s.bundle)
|
|
187
|
+
.sort((a, b) => (when(a) < when(b) ? 1 : -1))
|
|
188
|
+
const s = rows[0]
|
|
189
|
+
if (!s) return null
|
|
190
|
+
const file = perSessionFile(root, s.session_id)
|
|
191
|
+
return {
|
|
192
|
+
session_id: s.session_id, agent: s.agent,
|
|
193
|
+
from: s.lineage?.from ?? s.agent, to: s.lineage?.to ?? null,
|
|
194
|
+
at: when(s) || null, bundle: s.bundle?.id ?? null,
|
|
195
|
+
// a checkpoint bundle is not a hand-off, and only a hand-off leaves a
|
|
196
|
+
// per-session resume file; naming one that was never written is the same
|
|
197
|
+
// class of lie this module exists to stop
|
|
198
|
+
handed_off: Boolean(s.lineage?.to), file: existsSync(file) ? (file.includes('.leg') ? `.leg/RESUME-${s.session_id}.md` : `.baton/RESUME-${s.session_id}.md`) : null,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function idleBody(last, live) {
|
|
203
|
+
const lines = ['# Baton: nothing in flight', '']
|
|
204
|
+
lines.push(live.length
|
|
205
|
+
? `No hand-off is waiting to be picked up here. Still live in this checkout: ${live.map((s) => `${s.session_id} (${s.agent})`).join(', ')}.`
|
|
206
|
+
: 'No Baton terminal is live in this checkout.')
|
|
207
|
+
lines.push('')
|
|
208
|
+
if (last) {
|
|
209
|
+
const at = last.at ? String(last.at).slice(0, 16).replace('T', ' ') : 'an unrecorded time'
|
|
210
|
+
const bundle = last.bundle ? ` (bundle ${last.bundle})` : ''
|
|
211
|
+
lines.push(last.handed_off
|
|
212
|
+
? `The last hand-off here was ${last.from ?? 'an agent'} -> ${last.to} on ${at}${bundle}, in terminal ${last.session_id}.`
|
|
213
|
+
: `The last bundle saved here was a checkpoint from terminal ${last.session_id} (${last.agent ?? 'an agent'}) on ${at}${bundle}; no agent handed off.`)
|
|
214
|
+
if (last.file) lines.push(`Its full text is still in ${last.file}. It describes that moment, not this one.`)
|
|
215
|
+
} else {
|
|
216
|
+
lines.push('No hand-off has been recorded in this checkout.')
|
|
217
|
+
}
|
|
218
|
+
lines.push('', 'Before you trust any resume file here, run `leg resume --check`: it recomputes freshness from git at read time and exits non-zero when the file no longer matches the repository.')
|
|
219
|
+
lines.push('')
|
|
220
|
+
return lines.join('\n')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The "nothing in flight" pointer. Always writes; the callers that must not
|
|
224
|
+
// create a file in a checkout Baton never handed off in check first.
|
|
225
|
+
export function writeIdlePointer(root, { sessions = listSessions() } = {}) {
|
|
226
|
+
if (!root) return null
|
|
227
|
+
const dir = existsSync(join(root, '.baton')) && !existsSync(join(root, '.leg')) ? join(root, '.baton') : join(root, '.leg')
|
|
228
|
+
mkdirSync(dir, { recursive: true })
|
|
229
|
+
const live = liveIn(root, sessions)
|
|
230
|
+
const last = lastHandoffIn(root, sessions)
|
|
231
|
+
const stamp = makeStamp({ kind: 'idle', session: null, root, why: 'session ended', bundle: last?.bundle ? { id: last.bundle } : null, live })
|
|
232
|
+
const text = stampBody(stamp, idleBody(last, live))
|
|
233
|
+
writeFileSync(resumeFile(root), text)
|
|
234
|
+
return text
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// A session ending must not leave its hand-off sitting there looking live.
|
|
238
|
+
// Only ever rewrites a pointer that already exists: Baton owns RESUME.md where
|
|
239
|
+
// it wrote one, and creates none in a checkout it never handed off in.
|
|
240
|
+
export function endSessionPointer(session) {
|
|
241
|
+
const root = workRoot(session)
|
|
242
|
+
if (!root || !existsSync(resumeFile(root))) return null
|
|
243
|
+
return writeIdlePointer(root)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Board start: every checkout Baton wrote a pointer in gets it recomputed, so a
|
|
247
|
+
// terminal that crashed instead of exiting cannot leave a live-looking hand-off
|
|
248
|
+
// behind. A checkout whose terminal really is live keeps its hand-off text.
|
|
249
|
+
// Returns the roots rewritten.
|
|
250
|
+
export function refreshPointers() {
|
|
251
|
+
// a crashed terminal still reads `running` until its dead pid is noticed
|
|
252
|
+
const sessions = reapLost()
|
|
253
|
+
const roots = new Map()
|
|
254
|
+
for (const s of sessions) {
|
|
255
|
+
const r = workRoot(s)
|
|
256
|
+
if (r && existsSync(resumeFile(r))) roots.set(canonPath(r), r)
|
|
257
|
+
}
|
|
258
|
+
const touched = []
|
|
259
|
+
for (const root of roots.values()) {
|
|
260
|
+
const v = resumeVerdict(root, { sessions })
|
|
261
|
+
if (v.state === 'fresh') continue
|
|
262
|
+
// The terminal that wrote a hand-off owns it while it is still running: its
|
|
263
|
+
// text is the live description, and "the repo moved on" is for `baton resume
|
|
264
|
+
// --check` to report, not for the board to overwrite. Anything else — a
|
|
265
|
+
// hand-off from a terminal that is gone, a file no Baton stamped — is
|
|
266
|
+
// replaced even when some OTHER terminal happens to be live in the
|
|
267
|
+
// checkout, which is the case that left three day old text sitting there.
|
|
268
|
+
if (v.session?.active) continue
|
|
269
|
+
// the raw root, the way the session recorded it: this list is deduped by
|
|
270
|
+
// canonPath() above and then only logged, so normalising the spelling here
|
|
271
|
+
// renamed the checkout in the log line for no gain
|
|
272
|
+
try { writeIdlePointer(root, { sessions }); touched.push(root) } catch { /* a checkout that moved or went read-only */ }
|
|
273
|
+
}
|
|
274
|
+
return touched
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- reading the pointer: the verdict, from git, now ----
|
|
278
|
+
|
|
279
|
+
function describe(list) { return list.map((s) => `${s.id ?? s.session_id} (${s.agent})`).join(', ') }
|
|
280
|
+
|
|
281
|
+
// Recomputed on every read. Nothing here trusts the file about the present:
|
|
282
|
+
// the stamp says what was true when it was written, git says what is true now,
|
|
283
|
+
// and the verdict is the difference.
|
|
284
|
+
export function resumeVerdict(cwd, { sessions = listSessions() } = {}) {
|
|
285
|
+
const found = findResume(cwd)
|
|
286
|
+
if (!found) {
|
|
287
|
+
return { state: 'missing', exit_code: EXIT.missing, root: null, file: null, kind: null, stamp: null, reasons: ['there is no .baton/RESUME.md in this checkout'], summary: 'no resume pointer in this checkout', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
|
|
288
|
+
}
|
|
289
|
+
const { root, file } = found
|
|
290
|
+
let text = ''
|
|
291
|
+
try { text = readFileSync(file, 'utf8') } catch { /* raced a rewrite */ }
|
|
292
|
+
const stamp = readStamp(text)
|
|
293
|
+
if (!stamp) {
|
|
294
|
+
return { state: 'unstamped', exit_code: EXIT.unstamped, root, file, kind: null, stamp: null, reasons: ['this file carries no Baton stamp, so its freshness cannot be checked against git'], summary: 'cannot be checked: no Baton stamp', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const now = gitState(root)
|
|
298
|
+
const headMoved = Boolean(stamp.head) && Boolean(now.head) && stamp.head !== now.head
|
|
299
|
+
const since = headMoved ? commitsSince(root, stamp.head) : 0
|
|
300
|
+
const thenDirty = stamp.dirty ?? { count: 0, hash: null }
|
|
301
|
+
const dirtyChanged = (thenDirty.hash ?? null) !== (now.dirty.hash ?? null)
|
|
302
|
+
const live = liveIn(root, sessions)
|
|
303
|
+
const wasLive = Array.isArray(stamp.live) ? stamp.live : []
|
|
304
|
+
const gone = wasLive.filter((w) => !live.some((s) => s.session_id === w.id))
|
|
305
|
+
const started = live.filter((s) => !wasLive.some((w) => w.id === s.session_id))
|
|
306
|
+
const named = stamp.session ? sessions.find((s) => s.session_id === stamp.session) ?? null : null
|
|
307
|
+
const written = stamp.written_at ? Date.parse(stamp.written_at) : NaN
|
|
308
|
+
|
|
309
|
+
const reasons = []
|
|
310
|
+
// A hand-off describes a moment in the repository; a commit or an edit since
|
|
311
|
+
// then means the description no longer matches what the reader will see.
|
|
312
|
+
if (stamp.kind === 'handoff') {
|
|
313
|
+
if (headMoved) reasons.push(since === null ? 'HEAD is a different commit than this was written at' : `${since} commit${since === 1 ? '' : 's'} landed since this was written`)
|
|
314
|
+
if (dirtyChanged) reasons.push(`the working tree changed since this was written (${thenDirty.count} uncommitted file${thenDirty.count === 1 ? '' : 's'} then, ${now.dirty.count} now)`)
|
|
315
|
+
}
|
|
316
|
+
// Both kinds claim which terminals are live. That claim is falsifiable now.
|
|
317
|
+
if (gone.length) reasons.push(`terminal ${describe(gone)} is no longer live`)
|
|
318
|
+
if (started.length) reasons.push(`terminal ${describe(started)} started after this was written`)
|
|
319
|
+
|
|
320
|
+
const state = reasons.length ? 'stale' : 'fresh'
|
|
321
|
+
const summary = state === 'fresh'
|
|
322
|
+
? `current${Number.isFinite(written) ? ` (written ${ago(Date.now() - written)})` : ''}`
|
|
323
|
+
: reasons.slice(0, MAX_REASONS).join('; ')
|
|
324
|
+
return {
|
|
325
|
+
state, exit_code: EXIT[state], root, file,
|
|
326
|
+
kind: stamp.kind ?? null, stamp,
|
|
327
|
+
reasons: reasons.slice(0, MAX_REASONS), summary,
|
|
328
|
+
head: { then: stamp.head ?? null, now: now.head, moved: headMoved, commits_since: since, branch: now.branch },
|
|
329
|
+
dirty: { then: thenDirty.count, now: now.dirty.count, changed: dirtyChanged },
|
|
330
|
+
live: live.map((s) => ({ id: s.session_id, agent: s.agent, status: s.status })),
|
|
331
|
+
session: stamp.session ? { id: stamp.session, status: named?.status ?? null, active: Boolean(named && isActive(named)) } : null,
|
|
332
|
+
written_at: stamp.written_at ?? null,
|
|
333
|
+
age_ms: Number.isFinite(written) ? Date.now() - written : null,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function ago(ms) {
|
|
338
|
+
if (!Number.isFinite(ms) || ms < 0) return 'just now'
|
|
339
|
+
const m = Math.round(ms / 60000)
|
|
340
|
+
if (m < 1) return 'less than a minute ago'
|
|
341
|
+
if (m < 60) return `${m} minute${m === 1 ? '' : 's'} ago`
|
|
342
|
+
const h = Math.round(m / 60)
|
|
343
|
+
if (h < 48) return `${h} hour${h === 1 ? '' : 's'} ago`
|
|
344
|
+
return `${Math.round(h / 24)} days ago`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// What the board may see: the verdict without the local paths. A guest never
|
|
348
|
+
// reaches the drawer, but a screenshot of it travels further than the machine.
|
|
349
|
+
export function verdictForBoard(v) {
|
|
350
|
+
if (!v) return null
|
|
351
|
+
const { root, file, stamp, ...rest } = v
|
|
352
|
+
void root; void file; void stamp
|
|
353
|
+
return { ...rest, bundle: stamp?.bundle ?? null, why: stamp?.why ?? null }
|
|
354
|
+
}
|