@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.
Files changed (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. package/src/worktree.mjs +207 -0
@@ -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
+ }
@@ -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
+ }