-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathcli.ts
More file actions
408 lines (362 loc) · 18.3 KB
/
Copy pathcli.ts
File metadata and controls
408 lines (362 loc) · 18.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
#!/usr/bin/env node
/**
* JevLoop · 命令行入口
*
* `examples/demo.ts` 回答的是「这条 loop 长什么样」——它写死一个任务、
* 自带一个规则判定器,好让全新 clone 也能跑出数字。这个文件回答的是另一个
* 问题:「拿它干活」。区别不是参数多少,是**任务从哪来**:demo 的任务是写死的,
* 这里的任务由调用方给,工作目录由调用方给,判定后端按环境解析。
*
* 所以两者不合并 —— 合并会把 demo 里那个刻意的规则判定器(§8.6:规则属于
* 场景不属于内核)带进一个用户以为在跑真实判定的命令里。
*
* ── 子命令为什么是这三个 ──────────────────────────────────────
*
* `run` / `serve` / `spec` 分别对应这个仓库能被用起来的三种方式:
* 当库调(run)、当应用看(serve)、当格式检查(spec)。
* `spec` 是只有这里能做的那一个 —— 它打印 `DECISION.md` 编译成了什么,
* 包括**哪些谓词没编译出来**(那意味着一条不存在的闸门)。
*
* ── 为什么不能再拆 ──────────────────────────────────────────────
*
* **一句话说得完:把 argv 变成一个动作,把结果印出来。** 上面那三个子命令
* **共用同一套输出词汇** —— 颜色、对齐、`── xxx ──` 那种分隔标题、
* `dim()` / `bold()` 的用法。按子命令切开,那套词汇要再切出第三个文件,
* 而读的人得从三个地方拼出「这个 CLI 能做什么」,恰恰丢掉了入口文件唯一的
* 用处:**一眼看全**。
*
* 消费者也是同一个(终端前的人),而 §12 给的两条接缝(「输入输出形状变了」
* 或「消费者不是同一批人」)在这里都不成立。
*
* ⚠️ 真正撑大它的是 `runTask` 里那段**给人看的账目**(约 50 行
* `console.log`)。哪天真要拆,接缝在那里 —— 把它做成一个「把 result
* 印成账目」的函数,不是按子命令切。
*
* @module JevLoop/cli
*/
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { USAGE, parseArgv } from './cli-args.ts'
import { compilePolicy, compileQuestions } from './decision-compile.ts'
import { headline, parseDecisionDoc, schemaProblems, isGate, summarize } from './decisiondoc.ts'
import { describeGates, type GateOverrides } from './gates.ts'
// ⚠ `./decisions.ts` **不在这里静态 import**:它在**模块加载时**就把磁盘上那份
// DECISION.md 读进来、解析、并按块校验帧声明,不合格当场抛。静态 import 会让
// 这个 CLI **在打印任何东西之前**就崩掉(一串栈回溯),而 `spec` 的职责
// 正是诊断一份坏文件 —— 它必须能在「加载失败」的情况下仍然跑起来。
// 需要它的两处(`runTask` 的门限覆盖、`spec` 的帧层)各自动态 import。
/** 包根目录。编译后 `dist/cli.js` 与源码 `src/cli.ts` 都指回包根。 */
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
/** 只在真的接终端时上色 —— 管道和重定向里不该混进转义序列。 */
const COLOR = process.stdout.isTTY === true
const paint = (code: number) => (s: string) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s)
const dim = paint(2)
const bold = paint(1)
const yellow = paint(33)
const green = paint(32)
/**
* 降级/重试的提示。
*
* 按**每一个转换**各报一次,不是一个全局标志位:重试也走这个回调
* (`from === to`),全局标志会让先到的重试把后面真正的降级吃掉,
* 而降级是这两件事里更重要的那个。
*
* 两种情况分开说 —— 同一个后端再试一次**不是**降级,套用降级的句子
* 会印出「laya unavailable, falling back to laya」。
*/
function onceNotifier(): (err: unknown, from: string, to: string) => void {
const notified = new Set<string>()
return (err, from, to) => {
const key = `${from}→${to}`
if (notified.has(key)) return
notified.add(key)
const why = (err as Error).message.slice(0, 60)
console.error(
from === to
? yellow(` ▲ ${from}: ${why}`)
: yellow(` ▲ ${from} unavailable (${why}), falling back to ${to}`),
)
}
}
async function runTask(task: string, options: Map<string, string>): Promise<number> {
/*
★ 动态 import `./index.ts`,理由是**加载期副作用**:facade 转出 `decisions.ts`,
而那个模块在**模块加载时**就要一份合格的 `DECISION.md`(解析 + 帧声明都过),
不合格当场抛。静态 import 会把这个依赖挂在**整个 CLI** 上,于是
`jevloop spec` 在打印任何东西之前就死于栈回溯 —— 而它的职责正是诊断一份坏文件。
★ 这七个名字只有本函数用(`serve` 是把子进程拉起来,不用它们),
所以搬到这里就够了,不必动 `run`/`serve` 的其它部分。
*/
const { Decider, Meter, formatRatio, loadEnv, resolveGenerator, resolveProvider, runAgent } =
await import('./index.ts')
const cwd = resolve(options.get('cwd') ?? process.cwd())
if (!existsSync(cwd)) {
console.error(`✗ --cwd ${cwd} does not exist`)
return 1
}
// 从**用户的工作目录**读 .env,不是从包目录 —— 这是调用方的项目,key 属于它。
const env = loadEnv({ cwd })
const prefer = options.has('jev') ? 'jev' : options.has('laya') ? 'laya' : undefined
const provider = resolveProvider({
...(prefer ? { prefer } : {}),
...(process.env.JEVOS_SIDECAR ? { layaUrl: process.env.JEVOS_SIDECAR } : {}),
onFallback: onceNotifier(),
})
const generator = resolveGenerator({})
const meter = new Meter()
const decider = new Decider({
provider,
meter,
onWarn: (id, warnings) => {
for (const w of warnings) console.log(yellow(` ⚠ budget [${id}] ${w.message}`))
},
strict: options.has('strict'),
})
console.log(bold('\nJevLoop · run'))
console.log(dim(` task : ${task}`))
console.log(dim(` cwd : ${cwd}`))
console.log(dim(` decision : ${provider.name}`))
console.log(dim(` generator : ${generator.name}`))
if (env.loaded.length) console.log(dim(` .env : loaded ${env.loaded.join(', ')}`))
// 没有判定模型时**先说出来**。不说的话表现为「第一步就 escalate」,
// 而那看起来像 bug,不像缺配置 —— 排查方向会被完全带偏(§8.10)。
//
// ⚠️ 判据是**链头**,不是 `includes('mock')`。链尾永远是 Mock 兜底
// (`resolveProvider` 的默认 `lastResort`),所以拿「含不含 mock」去判
// 会在**配了 Jev 的时候也误报** —— 实测 `jev→laya→mock` 被判成「没有判定模型」。
// 链头是 mock 才真的没有可用的判定后端。
if (provider.name.split('→')[0] === 'mock') {
console.log('')
console.log(yellow(' ⚠ no decision model available — every step will escalate.'))
console.log(dim(' Set TYPESAFE_API_KEY, or run a local Laya sidecar on :7789.'))
}
console.log('')
console.log(bold(' ── loop trace ──────────────────────────────────────────'))
/*
门限覆盖。**在任何模型调用之前验完** —— 名字写错是致命的(见 `gates.ts`
文件头:静默无效等于你以为加了一道闸门),而在这里验意味着写错不花钱。
*/
let gates: GateOverrides = {}
try {
const { resolveGates } = await import('./decisions.ts')
gates = resolveGates(options.get('gate') ?? process.env.JEVLOOP_GATES ?? '')
} catch (err) {
console.error(`✗ ${(err as Error).message}`)
return 1
}
const result = await runAgent({
task,
cwd,
decider,
generator,
maxSteps: Number(options.get('max-steps') ?? 8),
onTrace: (line) => console.log(dim(line)),
gates,
// ★ 隔离确认来自环境:容器配方里设 `JEVLOOP_ISOLATED=1`(见 SECURITY.md)。
// **内核不读 env** —— 只有应用层知道这次部署长什么样(`Tool.requiresIsolation`)。
assumeIsolated: process.env.JEVLOOP_ISOLATED === '1',
})
const s = meter.stats
console.log('')
console.log(bold(' ── result ──────────────────────────────────────────────'))
console.log(` halt : ${result.halt}`)
console.log(` steps : ${result.steps}`)
console.log('')
console.log(dim(' ' + result.answer.split('\n').join('\n ').slice(0, 2000)))
console.log('')
console.log(bold(' ── accounting ──────────────────────────────────────────'))
console.log(` decisions ${green(String(s.decisions).padStart(3))} ${dim(`${s.decisionMs}ms (${s.avgDecisionMs}ms each)`)}`)
console.log(` model ${String(s.modelCalls).padStart(3)} ${dim(`${s.modelMs}ms`)}`)
console.log('')
console.log(` ${bold('decisions : model =')} ${bold(green(formatRatio(s)))}${dim(` decisions are ${(s.decisionShare * 100).toFixed(1)}% of wall clock`)}`)
// 覆盖过的门限**必须出现在给人看的那份账上**,不只在日志里 ——
// 否则两次结果不同时,读的人会去怀疑模型,而不是怀疑自己改过的那个数
const gateLine = describeGates(gates)
if (gateLine) console.log(yellow(` gates : ${gateLine} (覆盖了默认值)`))
console.log('')
/*
退出码按「**答完了吗**」判,不按「走了哪条路」。
★ `answered_directly` 漏了是真 bug(2026-09-21 实测):它和 `agent_done`
/ `task_done` 是**并列的成功出口** —— 三条都在 `agent.ts` 里 `break`
出来走同一条尾路(生成 → 过交付闸门 → 返回答案)。漏掉的表现是
`jevloop run "一句不用查资料的问题"` **打印一个好好的答案然后退出 1**,
而退出码是 `jevloop run … && …` 唯一看的东西。
⚠️ 带 `+revise` 后缀的不算成功(交付闸门修订过一次后仍然没放行),
所以这里精确匹配,不用前缀。
*/
const DONE = new Set(['answered_directly', 'agent_done', 'task_done'])
return DONE.has(result.halt) ? 0 : 1
}
/**
* 起界面。
*
* 两条路,取决于这个 `cli.js` 是从哪跑起来的:
*
* - **装出来的包**:`dist/cli.js` 旁边就是 `dist/server.js`(`tsc` 一起编的),
* 直接跑它。这条是 npm 路径,也是 `serve` 从包装出来能跑的原因。
* - **clone**:`src/cli.ts` 旁边是 `src/server.ts`,按类型剥离跑。
*
* 两者都能跑,是因为 `server.ts` 的 `ROOT` 是**往上找 `package.json`**,
* 不是「本文件所在目录」—— 从 `src/` 跑和从 `dist/` 跑都指回包根,
* 而 `web/` 和 `DECISION.md` 都在那儿。
*/
function serve(options: Map<string, string>): Promise<number> {
const here = dirname(fileURLToPath(import.meta.url))
const compiled = join(here, 'server.js')
const source = join(here, 'server.ts')
let script: string
let stripTypes = false
if (existsSync(compiled)) {
script = compiled
} else if (existsSync(source)) {
script = source
// 类型剥离 v22.6 引入、v22.18 才默认开启。低版本必须显式带这个标志。
const [major = 0, minor = 0] = process.versions.node.split('.').map(Number)
stripTypes = major < 22 || (major === 22 && minor < 18)
} else {
console.error(`✗ neither ${compiled} nor ${source} exists — this package is incomplete`)
return Promise.resolve(1)
}
const env = { ...process.env }
const cwd = options.get('cwd')
if (cwd) env.CWD_ROOT = resolve(cwd)
const port = options.get('port')
if (port) env.PORT = port
const host = options.get('host')
if (host) env.HOST = host
const child = spawn(process.execPath, [...(stripTypes ? ['--experimental-strip-types'] : []), script], {
stdio: 'inherit',
env,
})
return new Promise((done) => {
child.on('exit', (code) => done(code ?? 1))
child.on('error', (err) => {
console.error(`✗ could not start the server: ${err.message}`)
done(1)
})
})
}
/**
* 打印 `DECISION.md` 编译成了什么。
*
* 重点是**没编译出来的那部分**:一个认不出的谓词会退化成一条永不命中的规则,
* 也就是一道不存在的闸门,而它是 fail open 的。人写这份文件,所以这里必须出声。
*/
async function spec(fileArg: string | undefined): Promise<number> {
const local = join(process.cwd(), 'DECISION.md')
const file = fileArg ? resolve(fileArg) : existsSync(local) ? local : join(PKG_ROOT, 'DECISION.md')
let md: string
try {
md = readFileSync(file, 'utf8')
} catch (err) {
console.error(`✗ cannot read ${file}: ${(err as Error).message}`)
return 1
}
const doc = parseDecisionDoc(md)
const s = summarize(doc)
console.log(bold('\nJevLoop · spec'))
console.log(dim(` file : ${file}`))
console.log(dim(` headline : ${headline(doc)}`))
console.log(dim(` model : ${s.modelDecisions} decisions reach the decision model, ${s.codeDecisions} are decided by code`))
console.log('')
let broken = 0
for (const block of doc.blocks) {
const questions = compileQuestions(block)
const policy = compilePolicy(block)
const uncompiled = policy?.problems ?? []
broken += uncompiled.length
const asks = questions ? Object.keys(questions).join(', ') : '—'
const gate = isGate(block) ? ` ${yellow('gate')}` : ''
console.log(` ${bold(block.id.padEnd(14))} ${block.kind.padEnd(6)} ${dim(`asks: ${asks}`)}${gate}`)
for (const problem of uncompiled) console.log(` ${yellow('✗')} ${problem}`)
}
if (doc.problems.length > 0) {
console.log('')
console.log(yellow(` ${doc.problems.length} parse problem(s):`))
for (const p of doc.problems) console.log(yellow(` L${p.line}: ${p.message}`))
}
/*
── 第三层:帧声明 ──────────────────────────────────────────
★ 这一层以前**没有**。`frameSpecViolations` 写好了,但只被单测拿手拼的
spec 调过 —— 磁盘上这份文件从来没被它检查过。后果实测过(2026-09-23):
把 `needs_tool` 的 `- cwd —— …` 整行删掉,这个命令打印
「✓ parses clean and every predicate compiles」并 exit 0,
而 `cwd` 已经从声明里消失了 —— §8.14 那条「删掉一个字段之后没有任何
东西记得它曾经在过」原样活着。
所以一个判定的合规要三问:**读得懂吗**(parse)、**谓词编得出来吗**
(policy)、**看什么和不看什么都说清了吗**(frame)。
★ 第三层**不用自己算**:`decisions.ts` 在模块加载时就把同一份文件读一遍、
编出七份帧声明、跑完整性检查,不合格当场抛。所以这里只要试着加载它 ——
加载成功就是这一层通过,抛出来的那句话就是问题本身。
走生产的同一条路,比在这里另算一遍可信。
*/
let frameBad: string[] = []
if (doc.problems.length === 0) {
try {
await import('./decisions.ts')
} catch (err) {
// 换行分开打:抛出来的是一句提要 + 若干条具体缺什么,挤成一行没法读
frameBad = (err as Error).message.split('\n').map((s) => s.trim()).filter(Boolean)
}
} else {
// 解析都没过就不去碰它 —— 那时 `decisions.ts` 会抛出**同一批**解析错误,
// 混进帧层只会把同一个问题报两遍,还报错了层
frameBad = []
}
if (frameBad.length > 0) {
console.log('')
console.log(yellow(` ${frameBad.length} frame problem(s):`))
for (const m of frameBad) console.log(yellow(` ✗ ${m}`))
}
/*
── 第四层:版本声明 ────────────────────────────────────────
★ 它和上面三层分开,是因为**补救动作不同**:`problems` 说「文件写错了」,
这一层说「文件声称的语义我读不懂」。后者在一份旧文件上**是正常的** ——
文件没错,是消费者该说不。混在一起,这两种结论就再也分不开。
★ 版本单独放一层还有一个更硬的理由:一份**没写版本**的文件,其余三层
一条都不响,而它从此不再说自己按哪一版读。等语义真改了,它会被按新
语义读、一个错都不报 —— 那正是 §8.14 那类失败。
*/
const schemaBad = schemaProblems(doc)
if (schemaBad.length > 0) {
console.log('')
console.log(yellow(` ${schemaBad.length} schema problem(s):`))
for (const m of schemaBad) console.log(yellow(` ${m}`))
}
const problems = doc.problems.length + broken + frameBad.length + schemaBad.length
console.log('')
if (problems === 0) {
console.log(green(` ✓ parses clean, every predicate compiles, every judgement declares what it sees, and it says which schema it follows (${doc.schema})`))
}
console.log('')
return problems === 0 ? 0 : 1
}
async function main(): Promise<number> {
const argv = process.argv.slice(2)
if (argv.includes('--help') || argv.includes('-h') || argv[0] === undefined) {
console.log(USAGE)
return argv[0] === undefined ? 1 : 0
}
if (argv.includes('--version')) {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')) as { version: string }
console.log(pkg.version)
return 0
}
const { command, positional, options } = parseArgv(argv)
if (command === 'run') {
const task = positional.join(' ').trim()
if (!task) {
console.error('✗ run needs a task: jevloop run "list the files and explain them"')
return 1
}
return runTask(task, options)
}
if (command === 'serve') return serve(options)
if (command === 'spec') return spec(positional[0])
console.error(`✗ unknown command '${command}'\n`)
console.log(USAGE)
return 1
}
process.exitCode = await main()