@ucsandman/legcli 0.8.0 → 0.9.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 +45 -0
- package/NOTICE +8 -0
- package/README.md +601 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +21 -12
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +71 -0
- package/docs/README.md +2 -0
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +21 -0
- package/docs/board-guide.md +13 -0
- package/docs/cli-contracts.md +22 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +22 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +1 -2
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +75 -19
- package/src/auth.mjs +2 -2
- package/src/board/board.js +3 -3
- package/src/board/sessions.js +77 -3
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- package/src/hook.mjs +49 -49
- package/src/land.mjs +7 -35
- package/src/launcher.mjs +38 -26
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +5 -5
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +37 -3
- package/src/redact.mjs +1 -1
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +38 -10
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +6 -3
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +170 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +2 -2
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +5 -4
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- package/fixtures/live/grok/supervisor.log +0 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Registry gate for a main-branch publish. Checks @ucsandman/legcli and
|
|
3
|
+
// leg-agents, refuses to ship if their versions differ, and writes
|
|
4
|
+
// GITHUB_OUTPUT so ci.yml can publish only the missing package(s).
|
|
5
|
+
// Fail closed: network errors, invalid JSON, prereleases, stale versions,
|
|
6
|
+
// and metadata drift exit 1.
|
|
7
|
+
import { appendFileSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
|
+
const EXPECTED_REPOSITORY = 'https://github.com/ucsandman/legcli.git'
|
|
13
|
+
const PRIMARY_NAME = '@ucsandman/legcli'
|
|
14
|
+
const ALIAS_NAME = 'leg-agents'
|
|
15
|
+
|
|
16
|
+
const fail = (message) => { throw new Error(message) }
|
|
17
|
+
|
|
18
|
+
const stableVersion = (value) => {
|
|
19
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value)
|
|
20
|
+
if (!match) return null
|
|
21
|
+
const parts = match.slice(1).map(Number)
|
|
22
|
+
return parts.every(Number.isSafeInteger) ? parts : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const writeOutput = (name, value) => {
|
|
26
|
+
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`)
|
|
27
|
+
console.log(`${name}=${value}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function registryStatus (name, version) {
|
|
31
|
+
const candidate = stableVersion(version)
|
|
32
|
+
if (!candidate) fail(`invalid stable package version: ${name}@${version}`)
|
|
33
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`
|
|
34
|
+
let response
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(url, { headers: { Accept: 'application/json' } })
|
|
37
|
+
} catch (error) {
|
|
38
|
+
fail(`npm registry request failed for ${name}@${version}: ${error.message}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (response.status === 404) {
|
|
42
|
+
let packageResponse
|
|
43
|
+
try {
|
|
44
|
+
packageResponse = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { headers: { Accept: 'application/json' } })
|
|
45
|
+
} catch (error) {
|
|
46
|
+
fail(`npm package metadata request failed for ${name}: ${error.message}`)
|
|
47
|
+
}
|
|
48
|
+
if (packageResponse.status === 404) {
|
|
49
|
+
console.log(`${name}@${version} is missing (package not yet on npm); publication is required`)
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
if (packageResponse.status !== 200) {
|
|
53
|
+
fail(`npm package metadata for ${name} returned unexpected status ${packageResponse.status}`)
|
|
54
|
+
}
|
|
55
|
+
let metadata
|
|
56
|
+
try {
|
|
57
|
+
metadata = await packageResponse.json()
|
|
58
|
+
} catch {
|
|
59
|
+
fail(`npm package metadata for ${name} returned invalid JSON`)
|
|
60
|
+
}
|
|
61
|
+
const latestText = metadata?.['dist-tags']?.latest
|
|
62
|
+
const latest = stableVersion(latestText)
|
|
63
|
+
if (!latest) fail(`npm latest tag for ${name} is not a stable version: ${latestText ?? '(missing)'}`)
|
|
64
|
+
const changedPart = candidate.findIndex((part, index) => part !== latest[index])
|
|
65
|
+
const newer = changedPart !== -1 && candidate[changedPart] > latest[changedPart]
|
|
66
|
+
if (!newer) fail(`refusing to publish missing ${name}@${version} because npm latest is ${latestText}`)
|
|
67
|
+
console.log(`${name}@${version} is missing and newer than npm latest ${latestText}; publication is required`)
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
if (response.status !== 200) fail(`npm registry returned unexpected status ${response.status} for ${name}@${version}`)
|
|
71
|
+
let published
|
|
72
|
+
try {
|
|
73
|
+
published = await response.json()
|
|
74
|
+
} catch {
|
|
75
|
+
fail(`npm registry returned invalid JSON for existing ${name}@${version}`)
|
|
76
|
+
}
|
|
77
|
+
if (published?.name !== name || published?.version !== version) {
|
|
78
|
+
fail(`npm registry response did not match the requested ${name}@${version}`)
|
|
79
|
+
}
|
|
80
|
+
console.log(`${name}@${version} already exists; skipping publication`)
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
85
|
+
const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8'))
|
|
86
|
+
const alias = JSON.parse(readFileSync(join(root, 'packages', 'leg-agents', 'package.json'), 'utf8'))
|
|
87
|
+
const lockRoot = lock.packages?.['']
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
if (!lockRoot || pkg.name !== PRIMARY_NAME || lockRoot.name !== pkg.name || lockRoot.version !== pkg.version || lock.version !== pkg.version) {
|
|
91
|
+
fail('package.json and package-lock.json root metadata do not match')
|
|
92
|
+
}
|
|
93
|
+
if (pkg.repository?.url !== EXPECTED_REPOSITORY || lockRoot.repository?.url !== EXPECTED_REPOSITORY) {
|
|
94
|
+
fail('package repository metadata does not match the npm trusted publisher binding')
|
|
95
|
+
}
|
|
96
|
+
if (alias.name !== ALIAS_NAME) fail(`alias package name is ${alias.name}, expected ${ALIAS_NAME}`)
|
|
97
|
+
if (alias.version !== pkg.version) {
|
|
98
|
+
fail(`refusing to publish: ${ALIAS_NAME}@${alias.version} is not in lockstep with ${PRIMARY_NAME}@${pkg.version}`)
|
|
99
|
+
}
|
|
100
|
+
if (alias.dependencies?.[PRIMARY_NAME] !== pkg.version) {
|
|
101
|
+
fail(`refusing to publish: ${ALIAS_NAME} pins ${PRIMARY_NAME}@${alias.dependencies?.[PRIMARY_NAME] ?? '(missing)'}, expected ${pkg.version}`)
|
|
102
|
+
}
|
|
103
|
+
if (alias.repository?.url !== EXPECTED_REPOSITORY) {
|
|
104
|
+
fail('alias package repository metadata does not match the npm trusted publisher binding')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const primaryPublished = await registryStatus(pkg.name, pkg.version)
|
|
108
|
+
const aliasPublished = await registryStatus(alias.name, alias.version)
|
|
109
|
+
writeOutput('published', String(primaryPublished))
|
|
110
|
+
writeOutput('alias_published', String(aliasPublished))
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(`registry gate failed: ${error.message}`)
|
|
113
|
+
process.exitCode = 1
|
|
114
|
+
}
|
package/scripts/probe.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { scrub } from '../src/runner.mjs'
|
|
|
13
13
|
|
|
14
14
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
15
|
const SRC = join(ROOT, 'src')
|
|
16
|
-
const HOME = process.env.BATON_HOME || join(homedir(), '.baton')
|
|
16
|
+
const HOME = process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg'))
|
|
17
17
|
|
|
18
18
|
const args = {}
|
|
19
19
|
const argv = process.argv.slice(2)
|
|
@@ -28,11 +28,12 @@ const timeoutS = parseInt(args['timeout-s'] ?? '300', 10)
|
|
|
28
28
|
const adapter = await getAdapter(name)
|
|
29
29
|
const mode = args.mode ?? adapter.modes.default
|
|
30
30
|
|
|
31
|
-
const PROMPT = `Create a file named hello-${name}.txt in the current directory containing exactly the word hi. Then create the directory .
|
|
31
|
+
const PROMPT = `Create a file named hello-${name}.txt in the current directory containing exactly the word hi. Then create the directory .leg if it is missing and write the file .leg/DONE containing the single line: done. Do nothing else. Do not ask questions.\n`
|
|
32
32
|
|
|
33
33
|
const node = (script, a) => execFileSync(process.execPath, [script, ...a], { encoding: 'utf8', env: process.env })
|
|
34
34
|
|
|
35
35
|
// Fresh DONE marker per probe so "done=yes" is this CLI's own work.
|
|
36
|
+
rmSync(join(repo, '.leg', 'DONE'), { force: true })
|
|
36
37
|
rmSync(join(repo, '.baton', 'DONE'), { force: true })
|
|
37
38
|
rmSync(join(repo, `hello-${name}.txt`), { force: true })
|
|
38
39
|
|
|
@@ -84,7 +85,7 @@ const textOf = (f) => (existsSync(join(runDir, f)) ? readFileSync(join(runDir, f
|
|
|
84
85
|
const authSource = /another auth source/i.test(textOf('err.log') + textOf('out.log'))
|
|
85
86
|
const file = existsSync(join(repo, `hello-${name}.txt`))
|
|
86
87
|
&& readFileSync(join(repo, `hello-${name}.txt`), 'utf8').trim() === 'hi'
|
|
87
|
-
const done = existsSync(join(repo, '.baton', 'DONE'))
|
|
88
|
+
const done = existsSync(join(repo, '.leg', 'DONE')) || existsSync(join(repo, '.baton', 'DONE'))
|
|
88
89
|
const exit = run?.status === 'exited' ? run.exit_code : `${run?.status ?? 'unknown'}`
|
|
89
90
|
const parsed = adapter.parseResult(textOf('out.log'))
|
|
90
91
|
writeFileSync(join(out, 'parsed.json'), scrubPaths(JSON.stringify({ ...parsed, raw: undefined }, null, 2)) + '\n')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// seed-fake-cards — fills a
|
|
2
|
+
// seed-fake-cards — fills a LEG_HOME with N backlog cards (fake chain, build
|
|
3
3
|
// pipeline) through the same path the CLI uses (src/cards.mjs createCard), for
|
|
4
4
|
// perf tests and manual board load-testing.
|
|
5
5
|
// node scripts/seed-fake-cards.mjs --home <dir> --repo <git repo path> --count 50
|
|
@@ -19,8 +19,9 @@ if (!args.home) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir>
|
|
|
19
19
|
if (!args.repo) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50]\n'); process.exit(2) }
|
|
20
20
|
const count = parseInt(args.count ?? '50', 10) || 50
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
process.env.
|
|
22
|
+
// LEG_HOME must be set before store.mjs (and the ledger.mjs it imports) load.
|
|
23
|
+
process.env.LEG_HOME = resolve(args.home)
|
|
24
|
+
process.env.BATON_HOME = process.env.LEG_HOME
|
|
24
25
|
const { createCard } = await import('../src/cards.mjs')
|
|
25
26
|
|
|
26
27
|
const TITLES = ['Add retry to fetch', 'Fix flaky lease test', 'Refactor log tail', 'Wire up SSE health', 'Trim README', 'Bump adapter timeout', 'Dedupe blocked events', 'Guard null station', 'Speed up card list', 'Polish error copy']
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
// Seeds a throwaway
|
|
1
|
+
// Seeds a throwaway LEG_HOME with a floor that has something in every lane:
|
|
2
2
|
// two cards running, two waiting on a human, one queued and blocked by a lease
|
|
3
3
|
// another card holds, and a landing on the trunk. The floor is derived entirely
|
|
4
4
|
// from card records and their events (server.mjs floor()), so the states are
|
|
5
5
|
// written straight to the ledger rather than driven through real runs.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// LEG_HOME=<dir> node scripts/seed-floor-board.mjs <repo path>
|
|
8
8
|
//
|
|
9
|
-
// Serve it on a spare port with
|
|
9
|
+
// Serve it on a spare port with LEG_TRUST=never. NEVER 4747.
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { tmpdir } from 'node:os'
|
|
12
12
|
|
|
13
|
-
process.env.
|
|
13
|
+
process.env.LEG_HOME ||= process.env.BATON_HOME || join(tmpdir(), 'leg-floor-board')
|
|
14
|
+
process.env.BATON_HOME ||= process.env.LEG_HOME
|
|
14
15
|
const repo = process.argv[2] || join(tmpdir(), 'toy-demo')
|
|
15
16
|
const { createCard } = await import('../src/cards.mjs')
|
|
16
17
|
const { ledgerAppend, ledgerUpdate, cardDir } = await import('../src/store.mjs')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
|
-
// Seeds a throwaway
|
|
3
|
+
// Seeds a throwaway LEG_HOME shaped like a real working board: a few live
|
|
4
4
|
// terminals and a larger pile of lost and ended ones, long absolute temp paths,
|
|
5
5
|
// and an image tag in a prompt.
|
|
6
6
|
//
|
|
@@ -10,12 +10,13 @@ import { tmpdir } from 'node:os'
|
|
|
10
10
|
// fixture cost more than any other mistake in the v4 redesign.
|
|
11
11
|
//
|
|
12
12
|
// node scripts/seed-wes-board.mjs # seeds ./scripts/wes-home
|
|
13
|
-
//
|
|
13
|
+
// LEG_HOME=<dir> node scripts/seed-wes-board.mjs
|
|
14
14
|
//
|
|
15
|
-
// Serve it on an isolated port with a throwaway
|
|
15
|
+
// Serve it on an isolated port with a throwaway LEG_HOME and LEG_TRUST=never.
|
|
16
16
|
// NEVER 4747: that is the operator's live board with real sessions on it.
|
|
17
17
|
// a throwaway home under the OS temp dir, so nothing is written into the repo
|
|
18
|
-
process.env.
|
|
18
|
+
process.env.LEG_HOME ||= process.env.BATON_HOME || join(tmpdir(), 'leg-seed-board')
|
|
19
|
+
process.env.BATON_HOME ||= process.env.LEG_HOME
|
|
19
20
|
const { createSession, updateSession } = await import('../src/sessions.mjs')
|
|
20
21
|
const { recordUsage, markLimited } = await import('../src/usage.mjs')
|
|
21
22
|
const { spawn } = await import('node:child_process')
|
package/scripts/stripe-setup.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Create (idempotently) the two
|
|
2
|
+
// Create (idempotently) the two Leg plans in Stripe, their payment links,
|
|
3
3
|
// and the webhook endpoint for the site. Test mode by default, --live for the
|
|
4
4
|
// live account. Secrets never print: the webhook signing secret and the link
|
|
5
5
|
// URLs are written to .env (gitignored); ids and public URLs print.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sync-harness-engine — the one way the vendored harness engine changes.
|
|
3
|
+
//
|
|
4
|
+
// Leg's portable-harness subsystem (src/harness/) runs the Agnostic AI port
|
|
5
|
+
// engine (MIT, github.com/ucsandman/Agnostic-AI) as a library. The engine is
|
|
6
|
+
// copied byte for byte into src/harness/vendor/agnostic-ai/ so the package has
|
|
7
|
+
// no runtime dependency and no build step, and this script is the only thing
|
|
8
|
+
// that writes there:
|
|
9
|
+
//
|
|
10
|
+
// node scripts/sync-harness-engine.mjs <path-to-agnostic-ai-checkout>
|
|
11
|
+
// copy the engine files, record the upstream commit and every file's
|
|
12
|
+
// sha256 in src/harness/vendor/agnostic-ai/UPSTREAM.json
|
|
13
|
+
// node scripts/sync-harness-engine.mjs --check
|
|
14
|
+
// verify every vendored file still matches the recorded hash (runs in
|
|
15
|
+
// `npm test`); a hand edit under vendor/ fails the build, because a
|
|
16
|
+
// fix belongs upstream and then here through this script
|
|
17
|
+
// node scripts/sync-harness-engine.mjs --diff <path-to-agnostic-ai-checkout>
|
|
18
|
+
// list vendored files that differ from that checkout (exit 1 if any)
|
|
19
|
+
//
|
|
20
|
+
// Every verdict prints the number of files it looked at.
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { execFileSync } from 'node:child_process'
|
|
24
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
|
|
27
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
28
|
+
export const VENDOR_DIR = join(ROOT, 'src', 'harness', 'vendor', 'agnostic-ai')
|
|
29
|
+
export const MANIFEST = join(VENDOR_DIR, 'UPSTREAM.json')
|
|
30
|
+
|
|
31
|
+
// What Leg embeds: the engine as a library plus the two data files it reads by
|
|
32
|
+
// default and the hook shim its targets point at. The repo's own CLI
|
|
33
|
+
// (engine/harness/cli.cjs) and everything outside engine/harness stay upstream.
|
|
34
|
+
export const FILES = [
|
|
35
|
+
'LICENSE',
|
|
36
|
+
'engine/harness/README.md',
|
|
37
|
+
'engine/harness/index.cjs',
|
|
38
|
+
'engine/harness/common.cjs',
|
|
39
|
+
'engine/harness/bundle.cjs',
|
|
40
|
+
'engine/harness/capture.cjs',
|
|
41
|
+
'engine/harness/apply.cjs',
|
|
42
|
+
'engine/harness/status.cjs',
|
|
43
|
+
'engine/harness/toml.cjs',
|
|
44
|
+
'engine/harness/sources/claude.cjs',
|
|
45
|
+
'engine/harness/sources/codex.cjs',
|
|
46
|
+
'engine/harness/targets/generic.cjs',
|
|
47
|
+
'engine/harness/targets/claude.cjs',
|
|
48
|
+
'engine/harness/targets/codex.cjs',
|
|
49
|
+
'engine/harness/targets/gemini.cjs',
|
|
50
|
+
'engine/harness/targets/agy.cjs',
|
|
51
|
+
'engine/harness/targets/cursor.cjs',
|
|
52
|
+
'engine/hooks/shim.cjs',
|
|
53
|
+
'core/templates/targets.json',
|
|
54
|
+
'core/safety/guards.json',
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const posix = (p) => p.replace(/\\/g, '/')
|
|
58
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex')
|
|
59
|
+
// Line endings are normalised before hashing so a checkout with autocrlf and one
|
|
60
|
+
// without agree on what "identical" means.
|
|
61
|
+
const hashFile = (file) => sha256(readFileSync(file).toString('latin1').replace(/\r\n/g, '\n'))
|
|
62
|
+
|
|
63
|
+
function git(cwd, args) {
|
|
64
|
+
try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return null }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function walk(dir, out = []) {
|
|
68
|
+
for (const name of readdirSync(dir)) {
|
|
69
|
+
const full = join(dir, name)
|
|
70
|
+
if (statSync(full).isDirectory()) walk(full, out)
|
|
71
|
+
else out.push(full)
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function readManifest() {
|
|
77
|
+
return JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function sync(upstream) {
|
|
81
|
+
const src = resolve(upstream)
|
|
82
|
+
for (const rel of FILES) if (!existsSync(join(src, rel))) throw new Error(`upstream is missing ${rel}: ${src}`)
|
|
83
|
+
if (existsSync(VENDOR_DIR)) {
|
|
84
|
+
for (const f of walk(VENDOR_DIR)) if (posix(relative(VENDOR_DIR, f)) !== 'UPSTREAM.json') rmSync(f, { force: true })
|
|
85
|
+
}
|
|
86
|
+
const files = {}
|
|
87
|
+
for (const rel of FILES) {
|
|
88
|
+
const dest = join(VENDOR_DIR, rel)
|
|
89
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
90
|
+
copyFileSync(join(src, rel), dest)
|
|
91
|
+
files[rel] = hashFile(dest)
|
|
92
|
+
}
|
|
93
|
+
const commit = git(src, ['rev-parse', 'HEAD'])
|
|
94
|
+
const dirty = git(src, ['status', '--porcelain']) || ''
|
|
95
|
+
const manifest = {
|
|
96
|
+
_comment: 'Written by scripts/sync-harness-engine.mjs; never edited by hand. Every file under this directory is a byte-for-byte copy of the Agnostic AI engine at the commit below, and `npm test` fails if one drifts.',
|
|
97
|
+
upstream: 'https://github.com/ucsandman/Agnostic-AI',
|
|
98
|
+
license: 'MIT (see LICENSE beside this file)',
|
|
99
|
+
commit,
|
|
100
|
+
upstreamDirty: Boolean(dirty.split('\n').filter(Boolean).some((l) => FILES.some((rel) => l.endsWith(rel)))),
|
|
101
|
+
syncedAt: new Date().toISOString(),
|
|
102
|
+
files,
|
|
103
|
+
}
|
|
104
|
+
writeFileSync(MANIFEST, JSON.stringify(manifest, null, 2) + '\n')
|
|
105
|
+
return manifest
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function check() {
|
|
109
|
+
const manifest = readManifest()
|
|
110
|
+
const problems = []
|
|
111
|
+
let checked = 0
|
|
112
|
+
for (const [rel, hash] of Object.entries(manifest.files)) {
|
|
113
|
+
checked++
|
|
114
|
+
const file = join(VENDOR_DIR, rel)
|
|
115
|
+
if (!existsSync(file)) { problems.push(`${rel}: missing`); continue }
|
|
116
|
+
if (hashFile(file) !== hash) problems.push(`${rel}: differs from the recorded upstream hash (edit upstream, then re-run the sync)`)
|
|
117
|
+
}
|
|
118
|
+
for (const f of walk(VENDOR_DIR)) {
|
|
119
|
+
const rel = posix(relative(VENDOR_DIR, f))
|
|
120
|
+
if (rel === 'UNOWNED' || rel === 'UPSTREAM.json') continue
|
|
121
|
+
checked++
|
|
122
|
+
if (!manifest.files[rel]) problems.push(`${rel}: not in UPSTREAM.json (only the sync script adds files here)`)
|
|
123
|
+
}
|
|
124
|
+
return { problems, checked, commit: manifest.commit }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function diff(upstream) {
|
|
128
|
+
const manifest = readManifest()
|
|
129
|
+
const src = resolve(upstream)
|
|
130
|
+
const changed = []
|
|
131
|
+
let checked = 0
|
|
132
|
+
for (const rel of Object.keys(manifest.files)) {
|
|
133
|
+
checked++
|
|
134
|
+
const theirs = join(src, rel)
|
|
135
|
+
if (!existsSync(theirs)) { changed.push(`${rel}: gone upstream`); continue }
|
|
136
|
+
if (hashFile(theirs) !== hashFile(join(VENDOR_DIR, rel))) changed.push(rel)
|
|
137
|
+
}
|
|
138
|
+
return { changed, checked, commit: git(src, ['rev-parse', 'HEAD']) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
142
|
+
const [mode, arg] = process.argv.slice(2)
|
|
143
|
+
if (mode === '--check') {
|
|
144
|
+
const r = check()
|
|
145
|
+
for (const p of r.problems) process.stdout.write(` ${p}\n`)
|
|
146
|
+
process.stdout.write(`harness-engine: ${r.problems.length ? 'DRIFT' : 'ok'} checked=${r.checked} upstream=${r.commit ?? 'unknown'}\n`)
|
|
147
|
+
process.exit(r.problems.length ? 1 : 0)
|
|
148
|
+
}
|
|
149
|
+
if (mode === '--diff') {
|
|
150
|
+
if (!arg) { process.stderr.write('usage: sync-harness-engine.mjs --diff <agnostic-ai checkout>\n'); process.exit(2) }
|
|
151
|
+
const r = diff(arg)
|
|
152
|
+
for (const p of r.changed) process.stdout.write(` ${p}\n`)
|
|
153
|
+
process.stdout.write(`harness-engine: ${r.changed.length} of ${r.checked} vendored file(s) differ from ${arg} (${r.commit ?? 'no git'})\n`)
|
|
154
|
+
process.exit(r.changed.length ? 1 : 0)
|
|
155
|
+
}
|
|
156
|
+
if (!mode || mode.startsWith('--')) { process.stderr.write('usage: sync-harness-engine.mjs <agnostic-ai checkout> | --check | --diff <checkout>\n'); process.exit(2) }
|
|
157
|
+
const m = sync(mode)
|
|
158
|
+
process.stdout.write(`harness-engine: vendored ${Object.keys(m.files).length} file(s) from ${m.commit ?? 'no git'}${m.upstreamDirty ? ' (upstream working tree had uncommitted engine changes)' : ''}\n`)
|
|
159
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Keep packages/leg-agents on the same version as @ucsandman/legcli.
|
|
3
|
+
// Source of truth: the root package.json version and engines.node.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/sync-leg-agents.mjs write version, pin, LICENSE, NOTICE
|
|
6
|
+
// node scripts/sync-leg-agents.mjs --check exit 1 if any of those drifted
|
|
7
|
+
//
|
|
8
|
+
// npm test runs --check. `npm version` runs the writer so a bump cannot
|
|
9
|
+
// leave the alias behind.
|
|
10
|
+
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import { dirname, join, resolve } from 'node:path'
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
14
|
+
import { resolveCommand } from '../src/commands.mjs'
|
|
15
|
+
|
|
16
|
+
export const ALIAS_NAME = 'leg-agents'
|
|
17
|
+
export const PRIMARY_NAME = '@ucsandman/legcli'
|
|
18
|
+
export const PACKED_FILES = ['package.json', 'bin/leg.mjs', 'LICENSE', 'NOTICE', 'README.md']
|
|
19
|
+
|
|
20
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
|
+
|
|
22
|
+
export function inspect (rootDir = root) {
|
|
23
|
+
const alias = join(rootDir, 'packages', 'leg-agents')
|
|
24
|
+
const rootPkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'))
|
|
25
|
+
const aliasPkg = JSON.parse(readFileSync(join(alias, 'package.json'), 'utf8'))
|
|
26
|
+
const wrapper = readFileSync(join(alias, 'bin', 'leg.mjs'), 'utf8')
|
|
27
|
+
const rootLicense = readFileSync(join(rootDir, 'LICENSE'))
|
|
28
|
+
const rootNotice = readFileSync(join(rootDir, 'NOTICE'))
|
|
29
|
+
const aliasLicense = readFileSync(join(alias, 'LICENSE'))
|
|
30
|
+
const aliasNotice = readFileSync(join(alias, 'NOTICE'))
|
|
31
|
+
const problems = []
|
|
32
|
+
|
|
33
|
+
if (rootPkg.name !== PRIMARY_NAME) {
|
|
34
|
+
problems.push(`root package name is ${rootPkg.name}, expected ${PRIMARY_NAME}`)
|
|
35
|
+
}
|
|
36
|
+
if (aliasPkg.name !== ALIAS_NAME) {
|
|
37
|
+
problems.push(`alias package name is ${aliasPkg.name}, expected ${ALIAS_NAME}`)
|
|
38
|
+
}
|
|
39
|
+
if (aliasPkg.version !== rootPkg.version) {
|
|
40
|
+
problems.push(`alias version ${aliasPkg.version} != ${PRIMARY_NAME} ${rootPkg.version}`)
|
|
41
|
+
}
|
|
42
|
+
const pin = aliasPkg.dependencies?.[PRIMARY_NAME]
|
|
43
|
+
if (pin !== rootPkg.version) {
|
|
44
|
+
problems.push(`alias pins ${PRIMARY_NAME}@${pin ?? '(missing)'}, expected ${rootPkg.version}`)
|
|
45
|
+
}
|
|
46
|
+
if (aliasPkg.engines?.node !== rootPkg.engines?.node) {
|
|
47
|
+
problems.push(`alias engines.node ${aliasPkg.engines?.node} != root ${rootPkg.engines?.node}`)
|
|
48
|
+
}
|
|
49
|
+
if (aliasPkg.bin?.leg !== 'bin/leg.mjs') {
|
|
50
|
+
problems.push(`alias bin.leg is ${aliasPkg.bin?.leg}, expected bin/leg.mjs`)
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(rootPkg.files) || rootPkg.files.includes('packages') || rootPkg.files.some((f) => String(f).startsWith('packages/'))) {
|
|
53
|
+
problems.push('root package.json files must not ship packages/leg-agents')
|
|
54
|
+
}
|
|
55
|
+
if (!rootLicense.equals(aliasLicense)) problems.push('packages/leg-agents/LICENSE does not match root LICENSE')
|
|
56
|
+
if (!rootNotice.equals(aliasNotice)) problems.push('packages/leg-agents/NOTICE does not match root NOTICE')
|
|
57
|
+
if (!wrapper.startsWith('#!/usr/bin/env node')) {
|
|
58
|
+
problems.push('packages/leg-agents/bin/leg.mjs is missing the node shebang')
|
|
59
|
+
}
|
|
60
|
+
if (!wrapper.includes("require.resolve('@ucsandman/legcli/package.json')")) {
|
|
61
|
+
problems.push('alias wrapper does not resolve @ucsandman/legcli/package.json')
|
|
62
|
+
}
|
|
63
|
+
if (!wrapper.includes('pathToFileURL')) {
|
|
64
|
+
problems.push('alias wrapper must import via pathToFileURL so Windows absolute paths work')
|
|
65
|
+
}
|
|
66
|
+
if (!wrapper.includes("join(pkgRoot, 'bin', 'leg.mjs')")) {
|
|
67
|
+
problems.push('alias wrapper does not load bin/leg.mjs from the pinned package')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { rootPkg, aliasPkg, wrapper, problems, aliasDir: alias }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function packedFileList (dir) {
|
|
74
|
+
const { bin, args } = resolveCommand('npm pack --dry-run --json --ignore-scripts')
|
|
75
|
+
const stdout = execFileSync(bin, args, { cwd: dir, encoding: 'utf8', windowsHide: true })
|
|
76
|
+
const startArr = stdout.indexOf('[')
|
|
77
|
+
const startObj = stdout.indexOf('{')
|
|
78
|
+
const start = startArr === -1 ? startObj : startObj === -1 ? startArr : Math.min(startArr, startObj)
|
|
79
|
+
if (start === -1) throw new Error(`npm pack produced no JSON:\n${stdout}`)
|
|
80
|
+
const parsed = JSON.parse(stdout.slice(start))
|
|
81
|
+
const entry = Array.isArray(parsed) ? parsed[0] : parsed
|
|
82
|
+
const files = (entry.files || []).map((f) => f.path || f)
|
|
83
|
+
return files.sort()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sync (rootDir = root) {
|
|
87
|
+
const { rootPkg, aliasPkg } = inspect(rootDir)
|
|
88
|
+
const alias = join(rootDir, 'packages', 'leg-agents')
|
|
89
|
+
const next = {
|
|
90
|
+
...aliasPkg,
|
|
91
|
+
version: rootPkg.version,
|
|
92
|
+
dependencies: { ...aliasPkg.dependencies, [PRIMARY_NAME]: rootPkg.version },
|
|
93
|
+
engines: { ...aliasPkg.engines, node: rootPkg.engines.node }
|
|
94
|
+
}
|
|
95
|
+
writeFileSync(join(alias, 'package.json'), JSON.stringify(next, null, 2) + '\n')
|
|
96
|
+
copyFileSync(join(rootDir, 'LICENSE'), join(alias, 'LICENSE'))
|
|
97
|
+
copyFileSync(join(rootDir, 'NOTICE'), join(alias, 'NOTICE'))
|
|
98
|
+
return next
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const isMain = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
102
|
+
|
|
103
|
+
if (isMain) {
|
|
104
|
+
const check = process.argv.includes('--check')
|
|
105
|
+
if (!check) sync()
|
|
106
|
+
const { rootPkg, problems, aliasDir: dir } = inspect()
|
|
107
|
+
let packed
|
|
108
|
+
try {
|
|
109
|
+
packed = packedFileList(dir)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
problems.push(`npm pack --dry-run failed: ${error.message}`)
|
|
112
|
+
}
|
|
113
|
+
if (packed) {
|
|
114
|
+
const expected = [...PACKED_FILES].sort()
|
|
115
|
+
if (packed.join('\n') !== expected.join('\n')) {
|
|
116
|
+
problems.push(`packed files are [${packed.join(', ')}], expected [${expected.join(', ')}]`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (problems.length) {
|
|
120
|
+
console.error(`leg-agents is out of lockstep with ${PRIMARY_NAME}@${rootPkg.version}:\n`)
|
|
121
|
+
for (const p of problems) console.error(` - ${p}`)
|
|
122
|
+
console.error('\nRun: node scripts/sync-leg-agents.mjs')
|
|
123
|
+
process.exitCode = 1
|
|
124
|
+
} else {
|
|
125
|
+
console.log(`leg-agents: in lockstep with ${PRIMARY_NAME}@${rootPkg.version}`)
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/accounts.mjs
CHANGED
|
@@ -44,12 +44,11 @@ export function accountsFile() { return join(home(), 'accounts.json') }
|
|
|
44
44
|
export function accountDir(agent, name) { return join(home(), 'accounts', agent, name) }
|
|
45
45
|
|
|
46
46
|
export function readAccounts() {
|
|
47
|
-
const base = { claude: ['default'], codex: ['default'], agy: ['default'] }
|
|
47
|
+
const base = { claude: ['default'], codex: ['default'], agy: ['default'], grok: ['default'] }
|
|
48
48
|
if (!existsSync(accountsFile())) return base
|
|
49
49
|
try {
|
|
50
50
|
const j = JSON.parse(readFileSync(accountsFile(), 'utf8'))
|
|
51
51
|
for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => n !== 'default')]
|
|
52
|
-
if (Array.isArray(j?.grok)) base.grok = ['default', ...j.grok.filter((n) => n !== 'default')]
|
|
53
52
|
} catch {}
|
|
54
53
|
return base
|
|
55
54
|
}
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const adapter = {
|
|
|
19
19
|
forbiddenFlags: ['danger-full-access', '--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-hook-trust', '--full-auto', '--approve-for-me'],
|
|
20
20
|
resolve() {
|
|
21
21
|
if ((process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN)) {
|
|
22
|
-
const bin = process.env.BATON_CODEX_BIN
|
|
22
|
+
const bin = process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN
|
|
23
23
|
return { bin, viaNode: /\.(mjs|cjs|js)$/.test(bin), entry: null }
|
|
24
24
|
}
|
|
25
25
|
// The npm package's bin/codex.js only spawns the platform package's native
|