@ucsandman/legcli 0.8.0 → 0.10.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 (125) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/NOTICE +8 -0
  3. package/README.md +639 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +43 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +205 -0
  8. package/docs/README.md +5 -1
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +22 -0
  11. package/docs/board-guide.md +33 -1
  12. package/docs/cli-contracts.md +36 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +23 -1
  15. package/docs/faq.md +19 -0
  16. package/docs/getting-started.md +272 -251
  17. package/docs/harness.md +319 -0
  18. package/docs/history.md +172 -0
  19. package/docs/runtime-tap.md +156 -0
  20. package/fixtures/verified.json +1 -1
  21. package/package.json +7 -3
  22. package/scripts/build-docs-site.mjs +18 -4
  23. package/scripts/check-branding.mjs +118 -0
  24. package/scripts/check-claims.mjs +1 -1
  25. package/scripts/license-sign.mjs +1 -1
  26. package/scripts/limits-table.mjs +1 -1
  27. package/scripts/live-limits.mjs +1 -1
  28. package/scripts/npm-publish-gate.mjs +114 -0
  29. package/scripts/probe.mjs +4 -3
  30. package/scripts/seed-fake-cards.mjs +4 -3
  31. package/scripts/seed-floor-board.mjs +5 -4
  32. package/scripts/seed-wes-board.mjs +5 -4
  33. package/scripts/stripe-setup.mjs +1 -1
  34. package/scripts/sync-harness-engine.mjs +159 -0
  35. package/scripts/sync-leg-agents.mjs +127 -0
  36. package/src/accounts.mjs +6 -4
  37. package/src/adapters/codex.mjs +1 -1
  38. package/src/attach.mjs +125 -23
  39. package/src/auth.mjs +2 -2
  40. package/src/board/board.css +23 -1
  41. package/src/board/board.js +17 -5
  42. package/src/board/history.js +377 -0
  43. package/src/board/index.html +33 -0
  44. package/src/board/sessions.js +95 -7
  45. package/src/bundle.mjs +54 -8
  46. package/src/chain.mjs +1 -1
  47. package/src/contract.mjs +4 -3
  48. package/src/fsx.mjs +5 -2
  49. package/src/handoff.mjs +6 -6
  50. package/src/harness/cli.mjs +281 -0
  51. package/src/harness/fingerprint.mjs +68 -0
  52. package/src/harness/index.mjs +407 -0
  53. package/src/harness/registry.mjs +124 -0
  54. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  55. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  56. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  57. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  74. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  75. package/src/history/cli.mjs +159 -0
  76. package/src/history/common.mjs +119 -0
  77. package/src/history/index.mjs +429 -0
  78. package/src/history/providers/agy.mjs +91 -0
  79. package/src/history/providers/claude.mjs +161 -0
  80. package/src/history/providers/codex.mjs +133 -0
  81. package/src/history/providers/copilot.mjs +94 -0
  82. package/src/history/providers/grok.mjs +138 -0
  83. package/src/history/worktrees.mjs +116 -0
  84. package/src/hook.mjs +49 -49
  85. package/src/land.mjs +7 -35
  86. package/src/launcher.mjs +38 -26
  87. package/src/ledger.mjs +6 -6
  88. package/src/license.mjs +10 -9
  89. package/src/live-capture.mjs +1 -1
  90. package/src/mergequeue.mjs +5 -5
  91. package/src/orchestrator.mjs +28 -4
  92. package/src/preferences.mjs +37 -3
  93. package/src/redact.mjs +24 -6
  94. package/src/resume.mjs +17 -15
  95. package/src/runner.mjs +2 -2
  96. package/src/scheduler.mjs +1 -1
  97. package/src/server.mjs +224 -18
  98. package/src/session-detail.mjs +15 -1
  99. package/src/sessions.mjs +15 -3
  100. package/src/share.mjs +2 -2
  101. package/src/stations/agent.mjs +1 -1
  102. package/src/sync/dashclaw.mjs +4 -4
  103. package/src/synthesis.mjs +165 -0
  104. package/src/taps/agy.mjs +2 -2
  105. package/src/taps/claude-usage.mjs +1 -1
  106. package/src/taps/claude.mjs +177 -170
  107. package/src/taps/codex.mjs +286 -286
  108. package/src/taps/grok.mjs +2 -2
  109. package/src/taps/mod.mjs +340 -0
  110. package/src/trust.mjs +205 -36
  111. package/src/usage.mjs +5 -1
  112. package/src/worktree.mjs +6 -5
  113. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  114. package/fixtures/live/agy/err.log +0 -0
  115. package/fixtures/live/agy/out.log +0 -1
  116. package/fixtures/live/agy/supervisor.log +0 -2
  117. package/fixtures/live/claude/err.log +0 -0
  118. package/fixtures/live/claude/out.log +0 -1
  119. package/fixtures/live/claude/supervisor.log +0 -2
  120. package/fixtures/live/codex/err.log +0 -1
  121. package/fixtures/live/codex/out.log +0 -8
  122. package/fixtures/live/codex/supervisor.log +0 -2
  123. package/fixtures/live/grok/err.log +0 -32
  124. package/fixtures/live/grok/out.log +0 -7
  125. package/fixtures/live/grok/supervisor.log +0 -2
package/src/trust.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // trust — records the folder-trust answer each agent CLI asks for on its first
2
- // run in a directory, before Baton spawns that agent.
2
+ // run in a directory, before Leg spawns that agent.
3
3
  //
4
4
  // Why this exists: the handoff is the product. When claude hits its 5-hour
5
- // limit at 3am, Baton writes the bundle and starts codex in the same terminal
5
+ // limit at 3am, Leg writes the bundle and starts codex in the same terminal
6
6
  // with nobody there. If the incoming agent has never run in that folder it
7
7
  // stops on a full-screen "Is this a project you trust?" prompt and waits for a
8
8
  // keypress that is not coming, and the handoff the user paid for silently
@@ -10,24 +10,25 @@
10
10
  //
11
11
  // Each CLI already stores that answer in a file, so the fix is to write the
12
12
  // same answer the user would have clicked, for the repository they already
13
- // chose by typing `baton claude` in it:
13
+ // chose by typing `leg claude` in it:
14
14
  //
15
15
  // claude ~/.claude.json projects["<repo>"].hasTrustDialogAccepted
16
16
  // (or $CLAUDE_CONFIG_DIR/.claude.json)
17
17
  // codex ~/.codex/config.toml [projects."<repo>"] trust_level = "trusted"
18
- // agy ~/.gemini/trustedFolders.json "<repo>": "TRUST_FOLDER"
18
+ // agy ~/.gemini/antigravity-cli/settings.json trustedWorkspaces: ["<repo>", "<worktree>"]
19
+ // (~/.gemini/config/projects/default-cli-project.json defense in depth)
19
20
  //
20
21
  // For claude this is the documented remedy: its own permissions docs say to
21
22
  // "set projects[<path>].hasTrustDialogAccepted to true in ~/.claude.json, where
22
23
  // <path> is the repository root", and its error text prints the same sentence.
23
24
  //
24
- // Three rules this module holds to, because it writes files Baton does not own:
25
+ // Three rules this module holds to, because it writes files Leg does not own:
25
26
  // 1. Never create a config file that is not already there. A missing file
26
27
  // means that CLI has never run here, so its own first-run flow (login,
27
28
  // onboarding) is about to happen with the user present anyway. Skip.
28
29
  // 2. Never rewrite a file to say what it already says. No write, no risk.
29
30
  // 3. Never lose what is already in the file. Read, add, write atomically,
30
- // under the same cross-process lock the rest of Baton uses.
31
+ // under the same cross-process lock the rest of Leg uses.
31
32
  //
32
33
  // BATON_TRUST=never turns all of it off; the prompts come back.
33
34
  import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
@@ -37,7 +38,7 @@ import { realPath, withFileLock, writeJsonAtomic } from './fsx.mjs'
37
38
 
38
39
  // The repository root is the unit of trust: claude walks up from the working
39
40
  // directory to the repo root looking for the flag, so one record covers every
40
- // worktree Baton creates under <repo>/.baton-worktrees/ as well.
41
+ // worktree Leg creates under <repo>/.baton-worktrees/ as well.
41
42
  export function repoRootOf(dir) {
42
43
  let at = realPath(dir)
43
44
  const stop = parse(at).root
@@ -90,7 +91,7 @@ export function claudeProjectKey(repo) {
90
91
 
91
92
  // The canonical key, plus the spellings Claude Code used in older versions.
92
93
  // Older spellings are updated when they are already in the file and never
93
- // created, so Baton corrects a stale entry without littering the config.
94
+ // created, so Leg corrects a stale entry without littering the config.
94
95
  export function projectKeys(repo, existing = {}) {
95
96
  const key = claudeProjectKey(repo)
96
97
  const out = [key]
@@ -100,7 +101,7 @@ export function projectKeys(repo, existing = {}) {
100
101
  // The older spelling is the same path written with Windows separators, so it
101
102
  // is recognised by its spelling rather than by asking the host to resolve it:
102
103
  // matching on the exact string only worked on a host where a backslash is a
103
- // separator, and the entry Baton was meant to correct was left stale
104
+ // separator, and the entry Leg was meant to correct was left stale
104
105
  // anywhere else. Nothing is created here — an entry is only ever brought up
105
106
  // to date when it is already in the file.
106
107
  for (const k of Object.keys(existing)) {
@@ -113,7 +114,7 @@ export function projectKeys(repo, existing = {}) {
113
114
  // is what triggers the second prompt ("Allow external CLAUDE.md file imports?").
114
115
  // Claude Code reads a CLAUDE.md from every ancestor directory of the working
115
116
  // directory, so an import in C:\Projects\CLAUDE.md is external to a session in
116
- // C:\Projects\some-repo. Returns the resolved paths, so Baton can print exactly
117
+ // C:\Projects\some-repo. Returns the resolved paths, so Leg can print exactly
117
118
  // what it approved instead of approving something invisible.
118
119
  export function externalImports(cwd) {
119
120
  const start = realPath(cwd)
@@ -200,7 +201,7 @@ export function ensureClaudeTrust(repo, { env = process.env, cwd = repo } = {})
200
201
  wrote = [...new Set(changed)]
201
202
  })
202
203
  if (wrote === null) return { agent: 'claude', file, wrote: [], imports, skipped: 'claude config is not readable json' }
203
- if (wrote === 'declined') return { agent: 'claude', file, root, wrote: [], imports, skipped: 'you answered no for this folder; Baton leaves that answer alone' }
204
+ if (wrote === 'declined') return { agent: 'claude', file, root, wrote: [], imports, skipped: 'you answered no for this folder; Leg leaves that answer alone' }
204
205
  return { agent: 'claude', file, root, wrote, imports, skipped: null }
205
206
  }
206
207
 
@@ -225,7 +226,7 @@ export function ensureCodexTrust(repo, { env = process.env } = {}) {
225
226
  if (codexTrustedPaths(text).some((p) => samePath(p, root))) return
226
227
  const block = `\n[projects."${tomlPath(root)}"]\ntrust_level = "trusted"\n`
227
228
  // Append only: a TOML table added at the end cannot change the meaning of
228
- // a table above it, and Baton never reformats a file it did not write.
229
+ // a table above it, and Leg never reformats a file it did not write.
229
230
  writeTextAtomic(file, text.endsWith('\n') ? text + block : text + '\n' + block)
230
231
  wrote = ['trust_level']
231
232
  })
@@ -246,7 +247,12 @@ export function codexTrustedPaths(text) {
246
247
  }
247
248
 
248
249
  function samePath(a, b) {
249
- return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
250
+ if (typeof a !== 'string' || typeof b !== 'string') return false
251
+ const normA = a.replace(/\\/g, '/')
252
+ const normB = b.replace(/\\/g, '/')
253
+ if (process.platform === 'win32') return normA.toLowerCase() === normB.toLowerCase()
254
+ if (DRIVE_ABS.test(a) && DRIVE_ABS.test(b)) return normA.toLowerCase() === normB.toLowerCase()
255
+ return normA === normB
250
256
  }
251
257
 
252
258
  function writeTextAtomic(file, text) {
@@ -255,7 +261,7 @@ function writeTextAtomic(file, text) {
255
261
  for (let i = 0; i < 20; i++) {
256
262
  try { renameSync(tmp, file); return } catch (err) {
257
263
  // Windows holds a brief lock on a file another process has open; the
258
- // same retry the rest of Baton uses rather than lose the config.
264
+ // same retry the rest of Leg uses rather than lose the config.
259
265
  if (!['EPERM', 'EBUSY', 'EACCES', 'EEXIST'].includes(err.code)) { try { unlinkSync(tmp) } catch {} throw err }
260
266
  const until = Date.now() + 25
261
267
  while (Date.now() < until) { /* spin */ }
@@ -267,31 +273,194 @@ function writeTextAtomic(file, text) {
267
273
 
268
274
  // ---- agy ----
269
275
 
276
+ export function agySettingsFile(env = process.env) {
277
+ if (env.ANTIGRAVITY_APP_DATA_DIR) {
278
+ const p = resolve(env.ANTIGRAVITY_APP_DATA_DIR)
279
+ return p.endsWith('settings.json') ? p : join(p, 'settings.json')
280
+ }
281
+ const base = env.GEMINI_CONFIG_DIR ? resolve(env.GEMINI_CONFIG_DIR) : join(homedir(), '.gemini')
282
+ if (base.endsWith('settings.json')) return base
283
+ if (base.endsWith('antigravity-cli')) return join(base, 'settings.json')
284
+ return join(base, 'antigravity-cli', 'settings.json')
285
+ }
286
+
270
287
  export function agyTrustFile(env = process.env) {
271
- const dir = env.GEMINI_CONFIG_DIR ? resolve(env.GEMINI_CONFIG_DIR) : join(homedir(), '.gemini')
272
- return join(dir, 'trustedFolders.json')
288
+ const base = env.GEMINI_CONFIG_DIR ? resolve(env.GEMINI_CONFIG_DIR) : join(homedir(), '.gemini')
289
+ if (base.endsWith('default-cli-project.json')) return base
290
+ if (base.endsWith('projects')) return join(base, 'default-cli-project.json')
291
+ if (base.endsWith('config')) return join(base, 'projects', 'default-cli-project.json')
292
+ return join(base, 'config', 'projects', 'default-cli-project.json')
273
293
  }
274
294
 
275
- export function ensureAgyTrust(repo, { env = process.env } = {}) {
276
- const file = agyTrustFile(env)
295
+ export function agyTrustedFoldersFile(env = process.env) {
296
+ const base = env.GEMINI_CONFIG_DIR ? resolve(env.GEMINI_CONFIG_DIR) : join(homedir(), '.gemini')
297
+ if (base.endsWith('trustedFolders.json')) return base
298
+ return join(base, 'trustedFolders.json')
299
+ }
300
+
301
+ export function normalizeWorkspacePath(raw) {
302
+ const s = String(raw)
303
+ let p
304
+ if (DRIVE_ABS.test(s)) {
305
+ p = process.platform === 'win32' ? realPath(s) : s
306
+ p = p.replace(/\//g, '\\')
307
+ if (/^[a-z]:/i.test(p)) p = p[0].toUpperCase() + p.slice(1)
308
+ return p.length > 3 ? p.replace(/\\+$/, '') : p
309
+ }
310
+ p = realPath(s)
311
+ if (process.platform === 'win32') {
312
+ p = p.replace(/\//g, '\\')
313
+ if (/^[a-z]:/i.test(p)) p = p[0].toUpperCase() + p.slice(1)
314
+ return p.length > 3 ? p.replace(/\\+$/, '') : p
315
+ }
316
+ p = p.replace(/\\/g, '/')
317
+ return p.length > 1 ? p.replace(/\/+$/, '') : p
318
+ }
319
+
320
+ export function agyFolderUri(repo) {
277
321
  const root = repoRootOf(repo)
278
- // agy keeps trust in its own small file rather than a big config, so an
279
- // absent file next to an existing ~/.gemini is just an empty answer sheet.
280
- if (!existsSync(dirname(file))) return { agent: 'agy', file, wrote: [], imports: [], skipped: 'no agy config yet' }
322
+ let p = root.replace(/\\/g, '/')
323
+ if (!p.startsWith('/')) p = '/' + p
324
+ return `file://${p}`
325
+ }
326
+
327
+ export function agyUriMatches(uri, root) {
328
+ if (typeof uri !== 'string') return false
329
+ let p = uri.replace(/^file:\/\//, '').replace(/%3A/gi, ':')
330
+ if (/^\/[A-Za-z]:/.test(p)) p = p.slice(1)
331
+ return samePath(p, root)
332
+ }
333
+
334
+ export function ensureAgyTrust(repo, { env = process.env, cwd = repo } = {}) {
335
+ const settingsFile = agySettingsFile(env)
336
+ const projectFile = agyTrustFile(env)
337
+ const trustedFoldersFile = agyTrustedFoldersFile(env)
338
+
339
+ const settingsDir = dirname(settingsFile)
340
+ const projectDir = dirname(projectFile)
341
+
342
+ if (!existsSync(settingsDir) && !existsSync(projectDir) && !existsSync(settingsFile) && !existsSync(projectFile)) {
343
+ return { agent: 'agy', file: settingsFile, wrote: [], imports: [], skipped: 'no agy config yet' }
344
+ }
345
+
346
+ const root = normalizeWorkspacePath(repoRootOf(repo))
347
+ const workDir = normalizeWorkspacePath(cwd || repo)
348
+ const targets = samePath(root, workDir) ? [root] : [root, workDir]
349
+
350
+ // Rule 4: refusal preservation. If user answered "no" in any existing config, respect it.
351
+ if (existsSync(projectFile)) {
352
+ const project = readJson(projectFile)
353
+ if (project?.projectResources?.resources && Array.isArray(project.projectResources.resources)) {
354
+ const declined = project.projectResources.resources.some((res) => {
355
+ const uri = res.gitFolder?.folderUri ?? res.folderUri
356
+ return targets.some((t) => agyUriMatches(uri, t)) && res.gitFolder?.allowWrite === false
357
+ })
358
+ if (declined) {
359
+ return { agent: 'agy', file: settingsFile, root, wrote: [], imports: [], skipped: 'you answered no for this folder; Leg leaves that answer alone' }
360
+ }
361
+ }
362
+ }
363
+ if (existsSync(trustedFoldersFile)) {
364
+ const tf = readJson(trustedFoldersFile)
365
+ if (tf && typeof tf === 'object') {
366
+ const declined = targets.some((t) => tf[t] === 'DENY' || tf[t] === 'DONT_TRUST' || tf[t] === false)
367
+ if (declined) {
368
+ return { agent: 'agy', file: settingsFile, root, wrote: [], imports: [], skipped: 'you answered no for this folder; Leg leaves that answer alone' }
369
+ }
370
+ }
371
+ }
372
+
281
373
  let wrote = []
282
- withFileLock(`${file}.baton-lock`, () => {
283
- const map = existsSync(file) ? readJson(file) : {}
284
- if (map === null || typeof map !== 'object') { wrote = null; return }
285
- // Rule 4: a folder already in this file has been answered, whatever the
286
- // answer was. Only an absent key is an unanswered question; anything else
287
- // (DO_NOT_TRUST included) is the user's decision and stays.
288
- if (Object.prototype.hasOwnProperty.call(map, root)) { if (map[root] !== 'TRUST_FOLDER') wrote = 'declined'; return }
289
- writeJsonAtomic(file, { ...map, [root]: 'TRUST_FOLDER' })
290
- wrote = ['TRUST_FOLDER']
291
- })
292
- if (wrote === null) return { agent: 'agy', file, wrote: [], imports: [], skipped: 'agy trust file is not readable json' }
293
- if (wrote === 'declined') return { agent: 'agy', file, root, wrote: [], imports: [], skipped: 'you answered no for this folder; Baton leaves that answer alone' }
294
- return { agent: 'agy', file, root, wrote, imports: [], skipped: null }
374
+
375
+ // 1. ~/.gemini/antigravity-cli/settings.json -> trustedWorkspaces
376
+ // agy checks this file on startup: workspaceTrusted iterates settings.TrustedWorkspaces
377
+ // and does an exact string match (runtime.memequal) against Store.workspacePath.
378
+ const hasSettings = existsSync(settingsFile) || existsSync(settingsDir)
379
+ if (hasSettings) {
380
+ withFileLock(`${settingsFile}.baton-lock`, () => {
381
+ const settings = existsSync(settingsFile) ? readJson(settingsFile) : {}
382
+ if (!settings || typeof settings !== 'object') { wrote = null; return }
383
+ const list = Array.isArray(settings.trustedWorkspaces) ? [...settings.trustedWorkspaces] : []
384
+ let changed = false
385
+ for (const target of targets) {
386
+ const idx = list.findIndex((item) => typeof item === 'string' && samePath(item, target))
387
+ if (idx === -1) {
388
+ list.push(target)
389
+ changed = true
390
+ } else if (list[idx] !== target) {
391
+ // Fix casing / separator normalization so exact memequal matches in Go
392
+ list[idx] = target
393
+ changed = true
394
+ }
395
+ }
396
+ if (changed) {
397
+ settings.trustedWorkspaces = list
398
+ writeJsonAtomic(settingsFile, settings)
399
+ wrote.push('trustedWorkspaces')
400
+ }
401
+ })
402
+ if (wrote === null) {
403
+ return { agent: 'agy', file: settingsFile, wrote: [], imports: [], skipped: 'agy settings file is not readable json' }
404
+ }
405
+ }
406
+
407
+ // 2. ~/.gemini/config/projects/default-cli-project.json (Gemini project resources)
408
+ const hasProject = existsSync(projectFile) || existsSync(projectDir)
409
+ if (hasProject) {
410
+ withFileLock(`${projectFile}.baton-lock`, () => {
411
+ const project = existsSync(projectFile)
412
+ ? readJson(projectFile)
413
+ : { id: 'default-cli-project', name: 'CLI Project', projectResources: { resources: [] } }
414
+ if (!project || typeof project !== 'object') return
415
+ if (!project.projectResources || typeof project.projectResources !== 'object') {
416
+ project.projectResources = {}
417
+ }
418
+ if (!Array.isArray(project.projectResources.resources)) {
419
+ project.projectResources.resources = []
420
+ }
421
+ let changed = false
422
+ for (const target of targets) {
423
+ const alreadyTrusted = project.projectResources.resources.some((res) => {
424
+ const uri = res.gitFolder?.folderUri ?? res.folderUri
425
+ return agyUriMatches(uri, target)
426
+ })
427
+ if (!alreadyTrusted) {
428
+ project.projectResources.resources.push({
429
+ gitFolder: {
430
+ folderUri: agyFolderUri(target),
431
+ defaultBranch: 'main',
432
+ allowWrite: true,
433
+ },
434
+ })
435
+ changed = true
436
+ }
437
+ }
438
+ if (changed) {
439
+ writeJsonAtomic(projectFile, project)
440
+ wrote.push('gitFolder')
441
+ }
442
+ })
443
+ }
444
+
445
+ // 3. ~/.gemini/trustedFolders.json (legacy / extension support)
446
+ if (existsSync(trustedFoldersFile)) {
447
+ try {
448
+ withFileLock(`${trustedFoldersFile}.baton-lock`, () => {
449
+ const tf = readJson(trustedFoldersFile) || {}
450
+ let changed = false
451
+ for (const target of targets) {
452
+ if (tf[target] !== 'TRUST_FOLDER') {
453
+ tf[target] = 'TRUST_FOLDER'
454
+ changed = true
455
+ }
456
+ }
457
+ if (changed) writeJsonAtomic(trustedFoldersFile, tf)
458
+ })
459
+ } catch {}
460
+ }
461
+
462
+ const primaryFile = hasSettings ? settingsFile : projectFile
463
+ return { agent: 'agy', file: primaryFile, root, wrote, imports: [], skipped: null }
295
464
  }
296
465
 
297
466
  // ---- the one entry point ----
@@ -306,13 +475,13 @@ export function ensureTrust(agent, repo, { env = process.env, cwd = repo } = {})
306
475
  try {
307
476
  return fn(repo, { env, cwd })
308
477
  } catch (err) {
309
- // A trust record Baton could not write is a prompt the user will answer
478
+ // A trust record Leg could not write is a prompt the user will answer
310
479
  // themselves. It is never a reason to fail the session.
311
480
  return { agent, wrote: [], imports: [], skipped: `could not write the trust record: ${err.message}` }
312
481
  }
313
482
  }
314
483
 
315
- // The line Baton prints. Null when there is nothing worth saying.
484
+ // The line Leg prints. Null when there is nothing worth saying.
316
485
  export function trustLine(result) {
317
486
  if (!result || !result.wrote?.length) return null
318
487
  const what = result.agent === 'claude' && result.imports?.length
package/src/usage.mjs CHANGED
@@ -158,10 +158,14 @@ export function candidates({ agent, account = 'default', accounts, order = AGENT
158
158
  }
159
159
 
160
160
  // → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted by reset }
161
- export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000) }) {
161
+ // `exclude` names (agent, account) pairs this choice must skip: a destination
162
+ // the strict harness policy refused is neither available nor out, it is off
163
+ // the list for this hand-off.
164
+ export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
162
165
  const out = []
163
166
  for (const c of candidates({ agent, account, accounts, order })) {
164
167
  if (installed && installed[c.agent] === false) continue
168
+ if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) continue
165
169
  const u = readUsage(c.agent, c.account)
166
170
  if (isAvailable(u, nowS)) return { next: c, out }
167
171
  out.push({ ...c, resets_at: u.limited_until, reason: u.limited_reason })
package/src/worktree.mjs CHANGED
@@ -1,7 +1,8 @@
1
- // worktree — one git worktree per card under <repo>/.baton-worktrees/<cardId>,
2
- // on branch baton/<cardId>. Every git call runs with MSYS_NO_PATHCONV=1 (see
1
+ // worktree — one git worktree per card under <repo>/.leg-worktrees/<cardId>,
2
+ // on branch leg/<cardId> (legacy .baton-worktrees/ and baton/<id> still
3
+ // resolved). Every git call runs with MSYS_NO_PATHCONV=1 (see
3
4
  // git-snapshot.mjs) so Git Bash on Windows never rewrites an absolute path
4
- // argument. Never pushes; never deletes outside <repo>/.baton-worktrees/.
5
+ // argument. Never pushes; never deletes outside those worktree dirs.
5
6
  import { execFileSync } from 'node:child_process'
6
7
  import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
7
8
  import { join, resolve, sep, isAbsolute } from 'node:path'
@@ -63,7 +64,7 @@ export function validateRepo(repo) {
63
64
  return resolved
64
65
  }
65
66
 
66
- function parseWorktreeList(output) {
67
+ export function parseWorktreeList(output) {
67
68
  const entries = []
68
69
  let current = null
69
70
  for (const line of output.split(/\r?\n/)) {
@@ -85,7 +86,7 @@ export function list(repo) {
85
86
  return parseWorktreeList(out)
86
87
  }
87
88
 
88
- // Never committed by a landing: Baton's own directories, and the local state a
89
+ // Never committed by a landing: Leg's own directories, and the local state a
89
90
  // DashClaw hook writes into every directory an agent runs in.
90
91
  export function ensureExcludeEntries(repo) {
91
92
  // a linked worktree or submodule has `.git` as a FILE, so the shared info dir
@@ -1 +0,0 @@
1
- {"conversation_id":"f7ff607d-9831-4f28-83d2-f50fb07ad8a1","status":"SUCCESS","response":"Done. Both files created in `~\\.gemini\\antigravity-cli\\scratch\\`:\n\n- [hello-agy.txt](file:///~/.gemini/antigravity-cli/scratch/hello-agy.txt) — contains `hi`\n- [.baton/DONE](file:///~/.gemini/antigravity-cli/scratch/.baton/DONE) — contains `done`\n","duration_seconds":17.670068,"num_turns":1,"usage":{"input_tokens":39133,"output_tokens":542,"thinking_tokens":0,"cache_read_tokens":37387,"total_tokens":39675}}
File without changes
@@ -1 +0,0 @@
1
- {"conversation_id":"e36f7b41-8a0f-4666-a690-b49062d53de0","status":"SUCCESS","response":"Done. Created [hello-agy.txt](file:///~/AppData/Local/Temp/claude/C--Projects/49952fac-5ff4-486c-8395-9973cebdfdcb/scratchpad/toy-probe/hello-agy.txt) and [.baton/DONE](file:///~/AppData/Local/Temp/claude/C--Projects/49952fac-5ff4-486c-8395-9973cebdfdcb/scratchpad/toy-probe/.baton/DONE).\n","duration_seconds":36.3828431,"num_turns":1,"usage":{"input_tokens":39475,"output_tokens":600,"thinking_tokens":0,"cache_read_tokens":37477,"total_tokens":40075}}
@@ -1,2 +0,0 @@
1
- 2026-09-11T00:39:34.549Z spawning leg: adapter=agy run=1 mode=accept-edits cwd=~\AppData\Local\Temp\claude\C--Projects\49952fac-5ff4-486c-8395-9973cebdfdcb\scratchpad\toy-probe
2
- 2026-09-11T00:40:15.643Z leg exited code 0, session e36f7b41-8a0f-4666-a690-b49062d53de0
File without changes
@@ -1 +0,0 @@
1
- {"duration_api_ms":7804,"stop_reason":"end_turn","session_id":"d9e19d78-1502-4f88-8ded-81c5bbbda8c2","total_cost_usd":1.0015022500000001,"usage":{"input_tokens":34,"cache_creation_input_tokens":48486,"cache_read_input_tokens":47217,"output_tokens":372,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":48486,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":32,"output_tokens":45,"cache_read_input_tokens":47217,"cache_creation_input_tokens":1269,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1269},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":948,"outputTokens":18,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0010379999999999999,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":0,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"},"claude-fable-5-1":{"inputTokens":34,"outputTokens":372,"cacheReadInputTokens":47217,"cacheCreationInputTokens":48486,"webSearchRequests":0,"costUSD":1.00046425,"contextWindow":1000000,"maxOutputTokens":64000,"thinkingTokens":0,"canonicalModel":"claude-fable-5-1","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":3,"subtype":"success","api_error_status":null,"result":"Done. Both files exist:\n\n- `hello-claude.txt` contains `hi`\n- `.baton/DONE` contains `done`","ttft_ms":2791,"type":"result","duration_ms":11899,"uuid":"d70f3d4c-5a7f-4675-a734-d05edf37b1ed","ttft_stream_ms":1846,"time_to_request_ms":338,"first_content_frame_ms":1846,"queued_turn_count":0,"result_index":0}
@@ -1,2 +0,0 @@
1
- 2026-09-11T00:33:13.404Z spawning leg: adapter=claude run=1 mode=acceptEdits cwd=~\AppData\Local\Temp\claude\C--Projects\49952fac-5ff4-486c-8395-9973cebdfdcb\scratchpad\toy-probe
2
- 2026-09-11T00:33:33.400Z leg exited code 0, session d9e19d78-1502-4f88-8ded-81c5bbbda8c2
@@ -1 +0,0 @@
1
- Reading additional input from stdin...
@@ -1,8 +0,0 @@
1
- {"type":"thread.started","thread_id":"01a08de2-7611-7ff1-b0da-1e6e2cb7ca90"}
2
- {"type":"turn.started"}
3
- {"type":"item.completed","item":{"id":"item_0","type":"error","message":"Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."}}
4
- {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"`New-Item -ItemType File -Path hello-codex.txt -Value hi -Force` and `Set-Content` via `New-Item` in `.baton` (creating it only if missing), then writing `.baton/DONE` as `done`; I’m applying it now."}}
5
- {"type":"item.started","item":{"id":"item_2","type":"command_execution","command":"\"~\\\\AppData\\\\Local\\\\Microsoft\\\\WindowsApps\\\\pwsh.exe\" -Command '$ErrorActionPreference = '\"'Stop'; Set-Content -Path .\\\\hello-codex.txt -NoNewline -Value 'hi'; if (-not (Test-Path .\\\\.baton)) { New-Item -ItemType Directory -Path .\\\\.baton | Out-Null }; Set-Content -Path .\\\\.baton\\\\DONE -NoNewline -Value 'done'\"","aggregated_output":"","exit_code":null,"status":"in_progress"}}
6
- {"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"\"~\\\\AppData\\\\Local\\\\Microsoft\\\\WindowsApps\\\\pwsh.exe\" -Command '$ErrorActionPreference = '\"'Stop'; Set-Content -Path .\\\\hello-codex.txt -NoNewline -Value 'hi'; if (-not (Test-Path .\\\\.baton)) { New-Item -ItemType Directory -Path .\\\\.baton | Out-Null }; Set-Content -Path .\\\\.baton\\\\DONE -NoNewline -Value 'done'\"","aggregated_output":"","exit_code":0,"status":"completed"}}
7
- {"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Done. \n`hello-codex.txt` now contains exactly `hi`, and `.baton/DONE` contains exactly `done`."}}
8
- {"type":"turn.completed","usage":{"input_tokens":58585,"cached_input_tokens":10880,"cache_write_input_tokens":0,"output_tokens":853,"reasoning_output_tokens":650}}
@@ -1,2 +0,0 @@
1
- 2026-09-11T00:33:42.787Z spawning leg: adapter=codex run=1 mode=workspace-write cwd=~\AppData\Local\Temp\claude\C--Projects\49952fac-5ff4-486c-8395-9973cebdfdcb\scratchpad\toy-probe
2
- 2026-09-11T00:34:10.757Z leg exited code 0, session 01a08de2-7611-7ff1-b0da-1e6e2cb7ca90
@@ -1,32 +0,0 @@
1
-
2
- To sign in, open this URL in your browser:
3
-
4
- https://accounts.x.ai/oauth2/device?user_code=S3S6-5YEF
5
-
6
- Confirm this code in your browser:
7
-
8
- S3S6-5YEF
9
-
10
- Only continue with a code you requested. Don't share it with anyone.
11
-
12
- Waiting for authorization...
13
- 2026-09-11T00:37:22.879885Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.aggregate server=mole reason=tool name 'mole__mole.aggregate' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
14
- 2026-09-11T00:37:22.879980Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.citations server=mole reason=tool name 'mole__mole.citations' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
15
- 2026-09-11T00:37:22.880166Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.claim_add server=mole reason=tool name 'mole__mole.claim_add' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
16
- 2026-09-11T00:37:22.880194Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.claims_list server=mole reason=tool name 'mole__mole.claims_list' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
17
- 2026-09-11T00:37:22.880215Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.connect_list server=mole reason=tool name 'mole__mole.connect_list' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
18
- 2026-09-11T00:37:22.880237Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.dataset server=mole reason=tool name 'mole__mole.dataset' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
19
- 2026-09-11T00:37:22.880264Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.edge_add server=mole reason=tool name 'mole__mole.edge_add' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
20
- 2026-09-11T00:37:22.880280Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.fetch server=mole reason=tool name 'mole__mole.fetch' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
21
- 2026-09-11T00:37:22.880298Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.pairs_candidates server=mole reason=tool name 'mole__mole.pairs_candidates' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
22
- 2026-09-11T00:37:22.880319Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.rows_add server=mole reason=tool name 'mole__mole.rows_add' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
23
- 2026-09-11T00:37:22.880339Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.search server=mole reason=tool name 'mole__mole.search' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
24
- 2026-09-11T00:37:22.880354Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.session_close server=mole reason=tool name 'mole__mole.session_close' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
25
- 2026-09-11T00:37:22.880412Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.session_open server=mole reason=tool name 'mole__mole.session_open' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
26
- 2026-09-11T00:37:22.880437Z ERROR Skipping MCP tool with invalid name tool_name=mole__mole.verify_quote server=mole reason=tool name 'mole__mole.verify_quote' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
27
- 2026-09-11T00:37:22.880460Z ERROR Skipping MCP tool with invalid name tool_name=mole__research.ask server=mole reason=tool name 'mole__research.ask' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
28
- 2026-09-11T00:37:22.880482Z ERROR Skipping MCP tool with invalid name tool_name=mole__research.cancel server=mole reason=tool name 'mole__research.cancel' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
29
- 2026-09-11T00:37:22.880502Z ERROR Skipping MCP tool with invalid name tool_name=mole__research.report server=mole reason=tool name 'mole__research.report' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
30
- 2026-09-11T00:37:22.880519Z ERROR Skipping MCP tool with invalid name tool_name=mole__research.result server=mole reason=tool name 'mole__research.result' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
31
- 2026-09-11T00:37:22.880541Z ERROR Skipping MCP tool with invalid name tool_name=mole__research.sessions.list server=mole reason=tool name 'mole__research.sessions.list' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
32
- 2026-09-11T00:37:22.880557Z ERROR Skipping MCP tool with invalid name tool_name=mole__research.status server=mole reason=tool name 'mole__research.status' is invalid — must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ (start with letter/underscore, max 64 chars)
@@ -1,7 +0,0 @@
1
- {
2
- "text": "Creating `hello-grok.txt` and `.baton/DONE`.",
3
- "stopReason": "Cancelled",
4
- "sessionId": "01a08de5-c64c-7a31-ac58-1c11c2dd7332",
5
- "requestId": "fb815c5a-2e96-47c4-8bcc-30669f38e844",
6
- "thought": "The user wants me to:\n\n1. Create a file named hello-grok.txt in the current directory containing exactly the word hi"
7
- }
@@ -1,2 +0,0 @@
1
- 2026-09-11T00:36:48.222Z spawning leg: adapter=grok run=1 mode=acceptEdits cwd=~\AppData\Local\Temp\claude\C--Projects\49952fac-5ff4-486c-8395-9973cebdfdcb\scratchpad\toy-probe
2
- 2026-09-11T00:37:45.845Z leg exited code 0, session 01a08de5-c64c-7a31-ac58-1c11c2dd7332