@whyshu/argent-engine 0.7.3 → 0.7.5

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 (45) hide show
  1. package/MANIFEST.json +39 -28
  2. package/engine/VERSION +1 -1
  3. package/engine/scripts/appointment-create.mjs +34 -0
  4. package/engine/scripts/appointment-stop.mjs +18 -0
  5. package/engine/scripts/appointment-worker.mjs +96 -0
  6. package/engine/scripts/appointment_grab.py +327 -0
  7. package/engine/scripts/argent-runner.mjs +223 -0
  8. package/engine/scripts/argent-setup-check.mjs +3 -3
  9. package/engine/scripts/capability-packs.lock.json +33 -39
  10. package/engine/scripts/check-agent-catalog.mjs +1 -1
  11. package/engine/scripts/check-config-catalog.mjs +0 -1
  12. package/engine/scripts/collect-evidence.mjs +2 -5
  13. package/engine/scripts/daily-report.mjs +315 -12
  14. package/engine/scripts/dsh-upgrade.mjs +18 -5
  15. package/engine/scripts/flow-requirements.json +5 -7
  16. package/engine/scripts/flow-timers.sh +1 -1
  17. package/engine/scripts/flow-trigger.sh +1 -1
  18. package/engine/scripts/gen-preset-rows.mjs +20 -0
  19. package/engine/scripts/invoice_stageb.py +20 -1
  20. package/engine/scripts/lib/agent-install.mjs +10 -1
  21. package/engine/scripts/lib/daily-report-core.mjs +124 -16
  22. package/engine/scripts/lib/dsh-versions.mjs +17 -1
  23. package/engine/scripts/lib/ml-ads-core.mjs +204 -0
  24. package/engine/scripts/lib/plugin-packages.mjs +1 -0
  25. package/engine/scripts/ml-ads.mjs +181 -0
  26. package/engine/scripts/ml-oauth-setup.mjs +1 -1
  27. package/engine/scripts/p2-report-run.mjs +102 -0
  28. package/engine/scripts/patches/dsh-session--append-message-shape.patch +34 -9
  29. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +44 -0
  30. package/engine/scripts/patches/verify-hardening.mjs +4 -1
  31. package/engine/scripts/patches/verify-turn-outside-replace.mjs +64 -0
  32. package/engine/scripts/poll-fulfillment.sh +1 -1
  33. package/engine/scripts/profile-doctor.mjs +1 -1
  34. package/engine/scripts/python-runtime.sh +16 -4
  35. package/engine/scripts/release-argent.sh +8 -7
  36. package/engine/scripts/session-load-check.mjs +105 -0
  37. package/engine/scripts/sync-scripts.sh +1 -1
  38. package/engine/scripts/xzh-browser.sh +37 -0
  39. package/engine/scripts/xzh_browser.py +49 -0
  40. package/engine/skills/ml-full-appointment/SKILL.md +98 -0
  41. package/engine/version.mjs +1 -1
  42. package/package.json +1 -1
  43. package/engine/scripts/drill-clean-machine.sh +0 -315
  44. package/engine/scripts/install-internal-role.sh +0 -190
  45. package/engine/scripts/install-one-shot.mjs +0 -665
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-load-check —— 用**真实加载路径的校验**回放 session 日志(只读)。
4
+ *
5
+ * 与 `scripts/patches/verify-session-migration.mjs` 的分工(两者都必要,别互相替代):
6
+ * · verify-session-migration:跑**格式迁移链**(v0→v4),`validation: 'transformed'`
7
+ * ⇒ 只回答"能不能迁移";v4 文件是恒等迁移,**不会**触发 v4 结构校验。
8
+ * · 本工具:`validation: 'current'` ⇒ 触发 `restoreCurrent()`(= 宿主真正加载时走的校验,
9
+ * 含 turn/step 作用域、surface 折叠、工具生命周期等)。
10
+ *
11
+ * 为什么要它(2026-10-17 实测):主会话 06732069 在 0.2.0 上打不开
12
+ * (`SessionFormatError: tool/result is outside an open turn`),而 verify-session-migration
13
+ * 对同一个文件**判绿**——因为迁移校验不跑 v4 结构校验。别再拿迁移绿灯当"会话能打开"的证据。
14
+ *
15
+ * 用法:
16
+ * DSH_SCOPE=<…/@deepseek-ai> node scripts/session-load-check.mjs <session.vN.jsonl.zstd> [...]
17
+ * 不给文件 ⇒ 校验"当前用户近 N 天最常打开的几个 v4 会话"(默认 3 个,按 mtime 取最新)。
18
+ * 退出码:0 全绿 / 1 有失败 / 2 用法或环境错误。
19
+ *
20
+ * 边界(诚实):只做**读取 + 校验**,不迁移、不写任何文件;v3 及更早日志的 v3→v4 迁移需要
21
+ * 子会话事实,本工具传空数组 ⇒ 对 v3 文件的结论仅作参考(该场景用 verify-session-migration)。
22
+ */
23
+ import { createRequire } from 'node:module'
24
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+ import { requireDshScope } from './lib/dsh-scope.mjs'
27
+ import { decodeSessionLines } from './lib/zstd-frames.mjs'
28
+
29
+ let scope
30
+ try { scope = requireDshScope({ what: 'session 加载校验(session-load-check)' }) }
31
+ catch (e) { console.error(` ❌ ${e.message}`); process.exit(2) }
32
+ const requireGlobal = createRequire(scope.replace(/\/?$/, '/'))
33
+ const { createSessionFormatCatalogWithChildren, sessionFormatCatalog } =
34
+ requireGlobal('@deepseek-ai/dsh-session-format-catalog')
35
+
36
+ /** 取最近修改的 v4 日志(默认样本源)。 */
37
+ function recentV4(limit = 3) {
38
+ const root = join(process.env.DSH_HOME || join(process.env.HOME || '', '.dsh'), 'sessions')
39
+ const out = []
40
+ const walk = (dir, depth = 0) => {
41
+ let entries
42
+ try { entries = readdirSync(dir) } catch { return }
43
+ for (const name of entries) {
44
+ const p = join(dir, name)
45
+ let st
46
+ try { st = statSync(p) } catch { continue }
47
+ if (st.isDirectory()) { if (depth < 2) walk(p, depth + 1); continue }
48
+ if (/^session\.v4\.jsonl\.zstd$/.test(name)) out.push({ p, mtime: st.mtimeMs })
49
+ }
50
+ }
51
+ walk(root)
52
+ return out.sort((a, b) => b.mtime - a.mtime).slice(0, limit).map((x) => x.p)
53
+ }
54
+
55
+ function checkOne(file) {
56
+ let decoded
57
+ try { decoded = decodeSessionLines(readFileSync(file)) } catch (e) {
58
+ return { ok: false, why: `解压失败:${e.message}` }
59
+ }
60
+ const { header: headerRaw, lines } = decoded
61
+ const header = JSON.parse(headerRaw.split('\n')[0])
62
+ // v3 及更早:v3→v4 迁移需要子会话事实;本工具传空数组 ⇒ 结论仅供参考
63
+ const catalog = createSessionFormatCatalogWithChildren
64
+ ? createSessionFormatCatalogWithChildren([])
65
+ : sessionFormatCatalog
66
+ let restore
67
+ try { restore = catalog.createRestore(header, { recovery: 'strict', validation: 'current' }) } catch (e) {
68
+ return { ok: false, why: `createRestore 失败:${e.message}` }
69
+ }
70
+ let n = 0
71
+ for (const { raw } of lines) {
72
+ if (!raw.trim()) continue
73
+ n += 1
74
+ let ev
75
+ try { ev = JSON.parse(raw) } catch (e) { return { ok: false, why: `行 ${n} JSON 解析失败:${e.message}` } }
76
+ try { restore.decodeRow(ev) } catch (e) {
77
+ return { ok: false, why: `行 ${n} / seq ${ev.seq} / ${ev.type}:${e.name}: ${e.message}` }
78
+ }
79
+ }
80
+ try {
81
+ const artifact = restore.finish()
82
+ return { ok: true, detail: `v${header.version} → v${artifact.header.version},事件 ${artifact.events.length},行 ${n}`, header }
83
+ } catch (e) {
84
+ return { ok: false, why: `finish:${e.name}: ${e.message}` }
85
+ }
86
+ }
87
+
88
+ const files = process.argv.slice(2)
89
+ const targets = files.length ? files : recentV4(3)
90
+ if (targets.length === 0) { console.error(' ❌ 没有可校验的会话文件(给个路径,或确认 DSH_HOME/sessions 存在)'); process.exit(2) }
91
+
92
+ let failed = 0
93
+ for (const file of targets) {
94
+ const r = checkOne(file)
95
+ if (r.ok) {
96
+ console.log(`✅ ${file}`)
97
+ console.log(` ${r.detail}`)
98
+ } else {
99
+ failed += 1
100
+ console.log(`❌ ${file}`)
101
+ console.log(` ${r.why}`)
102
+ }
103
+ }
104
+ console.log(`\n${failed === 0 ? '✅' : '❌'} session-load-check:${targets.length - failed} 通过 / ${failed} 失败`)
105
+ process.exit(failed > 0 ? 1 : 0)
@@ -198,7 +198,7 @@ deliver_faces() {
198
198
  # ⚠️ `install-argent.mjs` 同族(2026-10-10 实测):克隆里它是**旧版**(仓库 ff4069d6 vs 克隆
199
199
  # 618f4bbd)⇒ 克隆侧跑 `--agent hr` 报「未知参数: hr」(新 flag 根本不存在)。
200
200
  # 它此前只靠 `argent-upgrade.mjs` 自举更新(滞后窗口 = 客户机装到旧安装器的风险)⇒ 一并进根文件清单。
201
- ROOT_FILES=(VERSION install-argent.mjs)
201
+ ROOT_FILES=(VERSION)
202
202
  deliver_root_files() {
203
203
  local f
204
204
  for f in "${ROOT_FILES[@]}"; do
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # 鑫中航常驻浏览器:启动/停止/状态(保留一个标签页给登录态)
3
+ set -u
4
+ ROOT="${ARGENT_DSH_HOME:-$HOME/.argent-dsh}"
5
+ DATA="${ARGENT_DATA_DIR:-$HOME/.dsh/argent}"
6
+ PY="$DATA/venv/bin/python3"
7
+ [ -x "$PY" ] || PY="$(command -v python3)"
8
+ LOG="$DATA/xzh-browser.log"
9
+ PORT="${XZH_CDP_PORT:-9223}"
10
+ case "${1:-status}" in
11
+ start)
12
+ if curl -s -m 2 "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
13
+ echo "already running: CDP $PORT"; exit 0
14
+ fi
15
+ mkdir -p "$DATA"
16
+ nohup "$PY" "$ROOT/scripts/xzh_browser.py" >>"$LOG" 2>&1 &
17
+ echo $! > "$DATA/xzh-browser.pid"
18
+ for i in $(seq 1 20); do
19
+ if curl -s -m 1 "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
20
+ echo "started: CDP $PORT"; exit 0
21
+ fi
22
+ sleep 1
23
+ done
24
+ echo "start failed; see $LOG"; tail -20 "$LOG"; exit 1
25
+ ;;
26
+ stop)
27
+ PID_FILE="$DATA/xzh-browser.pid"
28
+ if [ -f "$PID_FILE" ]; then kill "$(cat "$PID_FILE")" 2>/dev/null || true; rm -f "$PID_FILE"; fi
29
+ # 只杀监听该调试端口的 Chrome(避免误杀用户其它窗口)
30
+ PIDS=$(lsof -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true)
31
+ [ -n "$PIDS" ] && kill $PIDS 2>/dev/null || true
32
+ echo stopped
33
+ ;;
34
+ status)
35
+ if curl -s -m 2 "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then echo "running CDP $PORT"; else echo "not running"; exit 1; fi
36
+ ;;
37
+ esac
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ """鑫中航常驻浏览器:保留一个标签页/登录态;invoice_stageb 通过 CDP 复用。
3
+
4
+ 用法:
5
+ python3 scripts/xzh_browser.py # 前台常驻(调试)
6
+ nohup ... > ~/.dsh/argent/xzh-browser.log 2>&1 &
7
+ 启动后监听 127.0.0.1:9223;invoice_stageb 会自动 connect_over_cdp。
8
+ """
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent / 'lib'))
16
+ from argent_datadir import resolve_data_dir # noqa: E402
17
+
18
+ DATA = Path(resolve_data_dir()['dir'])
19
+ CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
20
+ PROFILE = str(DATA / "chrome-profile-xinhang")
21
+ PORT = int(os.environ.get("XZH_CDP_PORT", "9223"))
22
+
23
+
24
+ def main():
25
+ from playwright.sync_api import sync_playwright
26
+ mapping = json.load(open(DATA / "invoice-mapping.json"))
27
+ url = mapping["xinzhonghang"]["url"]
28
+ home = url.replace("/Login/VueLoginView", "") + "/home/EleIndex?lang=cn"
29
+ p = sync_playwright().start()
30
+ ctx = p.chromium.launch_persistent_context(
31
+ user_data_dir=PROFILE,
32
+ executable_path=CHROME,
33
+ headless=False,
34
+ viewport={"width": 1440, "height": 900},
35
+ accept_downloads=True,
36
+ args=[f"--remote-debugging-port={PORT}"],
37
+ )
38
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
39
+ try:
40
+ page.goto(home, timeout=20000, wait_until="domcontentloaded")
41
+ except Exception as exc:
42
+ print(f"⚠️ 打开首页失败(保留已开标签): {exc}", flush=True)
43
+ print(f"✅ 鑫中航常驻浏览器已启动:CDP http://127.0.0.1:{PORT}|profile={PROFILE}", flush=True)
44
+ while True:
45
+ time.sleep(30)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: ml-full-appointment
3
+ description: 美客多 FULL 抢预约对话与 ERP 任务创建(BOT/gateway 只做桥;真正预约由 ERP worker 执行)。处理 SKU/货件号、字段缺口、任意日期范围、二次确认、停止与不跑步骤5-8。
4
+ ---
5
+
6
+ # 美客多 FULL 抢预约(ml-full-appointment)
7
+
8
+ ## 定位
9
+
10
+ 飞书 @美客多 FULL BOT 只是**对话/确认桥梁**:
11
+
12
+ - BOT/gateway 负责:识别意图、补齐字段、二次确认、创建 ERP 预约任务、反馈“已受理/已停止”;
13
+ - **BOT/gateway 不轮询、不操作浏览器**;
14
+ - 真正抢预约由 Huangwei 的 **ERP appointment worker** 执行;
15
+ - 成功后通知发起人;**只到预约成功,不进入 FULL 步骤 5–8**。
16
+
17
+ ## 触发意图
18
+
19
+ 用户 @BOT 说类似:
20
+
21
+ - “抢预约 SKU HW-MX-168,店铺 墨西哥-1店-子账号,10/20 到 10/25”
22
+ - “抢货件 79922453 的预约,今天起 2 天内”
23
+ - “帮我抢 FULL 预约,30 天内的”
24
+
25
+ ## 必须收集的字段
26
+
27
+ - **店铺**(必填;SKU 可能跨店)
28
+ - **SKU 或货件号**(二选一;货件号优先续传)
29
+ - **日期窗口**:用户本次给的具体要求,两种模式:
30
+ - 用户说 `最近5天/最近5个可用日` ⇒ `dateMode=available_days`, `availableDays=5`(跳过 disabled,取最近 5 个可选日,不是日历固定 5 天);
31
+ - 用户给绝对范围 `2026-10-20 ~ 2026-10-25` ⇒ `dateMode=range`, `dateFrom/dateTo`;
32
+ - 若只给 SKU:**不读取 lip 表**,必须通过对话补齐 FULL 建单所需字段:店铺、SKU、数量 `quantity`、箱数 `boxCount`(产品名可选);缺什么问什么;
33
+ - 若给货件号:校验该货件存在,店铺/记录一致,当前进度允许预约;
34
+ - 所有参数以任务形式传给 ERP,worker 只使用任务参数;
35
+ - 轮询间隔默认 60 秒(后台参数,不需用户每次提供)。
36
+
37
+ 不要把 7/14 天写成固定变量;**每个任务保存用户本次的 dateFrom/dateTo**。
38
+
39
+ ## 二次确认(必须)
40
+
41
+ 创建任务前回显一段摘要,例如:
42
+
43
+ > 确认信息:店铺 墨西哥-1店-子账号;SKU HW-MX-168;预约日期 2026-10-20 ~ 2026-10-25;只抢预约,不继续箱唛/发票。回复「开始」才执行。
44
+
45
+ 用户明确说「开始」/「确认」后才创建 ERP 任务;未确认不得创建。
46
+
47
+ ## 创建 ERP 任务
48
+
49
+ BOT/gateway 通过确定性 CLI 创建任务(不在会话里轮询):
50
+
51
+ ```bash
52
+ node ~/.argent-dsh/scripts/appointment-create.mjs --json '<任务 JSON>' [--chat-id <当前群> --open-id <发起人open_id>]
53
+ ```
54
+
55
+ 返回 `{ ok, id, status }` 后,BOT 回复“已创建任务 #id”。
56
+
57
+ 尽量传 `--chat-id`/`--open-id`,worker 完成/停止时服务端会用 FULL BOT 通知发起人并附逐日结果。
58
+
59
+ CLI 内部读取 `~/.dsh/argent/config.json` 的 `apiKey`(或 `WHYSHU_API_KEY`),调用:
60
+
61
+ ```
62
+ POST /api/argent/v1/tenants/<tenantAppId>/appointments
63
+ {
64
+ "store": "...",
65
+ "sku": "...", // 或 shipmentNo
66
+ "shipmentNo": "...",
67
+ "quantity": 100, // SKU-only 必填
68
+ "boxCount": 10, // SKU-only 必填
69
+ "productName": "", // 可选
70
+ "dateMode": "available_days",
71
+ "availableDays": 5,
72
+ // range 模式才填 dateFrom/dateTo
73
+ "windowText": "10/20-10/25",
74
+ "pollIntervalSec": 60
75
+ }
76
+ ```
77
+
78
+ 返回任务 `queued` 后,BOT 回复:
79
+
80
+ > 已创建抢预约任务 #<id>,worker 会按 60 秒一轮执行;回复「停止」可终止。
81
+
82
+ ## 用户停止
83
+
84
+ 用户说「停止」时运行:
85
+
86
+ ```bash
87
+ node ~/.argent-dsh/scripts/appointment-stop.mjs <AppointmentTask id>
88
+ ```
89
+
90
+ 只置停止请求,worker 收尾;BOT 不轮询任务状态。
91
+
92
+ ## 禁止
93
+
94
+ - ⛔ 不在 BOT/gateway 会话里执行浏览器操作或循环轮询;
95
+ - ⛔ 不跑 FULL 步骤 5–8(箱唛/标签/发票/完成态);
96
+ - ⛔ 不把 7/14 天硬编码;不替用户改日期范围;
97
+ - ⛔ 未二次确认不创建任务;
98
+ - ⛔ 同一店铺预约仍走 ERP/编排器的同店步骤4互斥锁,避免与 FULL 任务串预约。
@@ -1 +1 @@
1
- export const VERSION = "0.7.3"
1
+ export const VERSION = "0.7.5"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whyshu/argent-engine",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Argent 引擎(L1):流程/台账/重试/守卫/审计/浏览器自动化 —— 逐字节来自 argent-dsh 仓库",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,315 +0,0 @@
1
- #!/usr/bin/env bash
2
- # drill-clean-machine.sh —— **干净机器一条命令**的隔离演练(D1/D2 的可复现证据;2026-10-05)
3
- #
4
- # 用途:把 `scripts/install-one-shot.mjs` 放进一个**与真实机器无关的夹具**里真跑一遍,
5
- # 并按"真实目录零变化"哨兵自证**没有**碰过 `~/.dsh/**`、`~/.argent-dsh/**`。
6
- #
7
- # 隔离构造(三条,缺一不可):
8
- # ① `HOME` 指向 `$FIX/home`(全新空目录)⇒ `~/.dsh`、`~/.argent-dsh`、`~/.pnpm` 全落在夹具里;
9
- # ② 子进程一律 `env -i`(**极简环境**:只给 HOME/PATH,清掉 DSH_HOME/ARGENT_DATA_DIR/密钥等实例级开关);
10
- # ③ PATH 只含**夹具 bin**(node/git/dsh 的软链)+ 系统目录 ⇒ 包管理器**不在** PATH 上
11
- # (演练刻意不跑 pnpm —— 装配走安装器的 `--from-packs` 离线面)。
12
- # 能力来源 = **离线包目录**(`capability_pack.py --build` 现场构建),不依赖 `~/.argent-dsh` 克隆。
13
- #
14
- # 演练内容(六跑):
15
- # A. dry-run ⇒ 退出码 0,且夹具**零写入**(dry-run 语义)
16
- # B. --apply(无许可) ⇒ 退出码 0,装配 + 技能同步真跑;许可段 skipped("未装许可"不算失败)
17
- # C. 重跑 B ⇒ 退出码 0,逐段结论与 B 一致(**幂等**)
18
- # D. --only license --apply(夹具许可)⇒ 退出码 0,许可真落到夹具数据目录
19
- # F. 在线装配面(本地镜像克隆 file://<仓库>、零网络、无包管理器 ⇒ 安装器既有"直拷"回退)
20
- # E. 多包一起装(full,invoice)⇒ **预期退出码 0**(2026-10-09 起):注册表投影在计划阶段与落盘阶段
21
- # 用**同一个**判据豁免(`isRegistryProjection()`),落盘后盘上注册表 = 两份投影的并集。
22
- # 收尾:哨兵比对(真实目录零变化)+ 夹具树/清单快照
23
- #
24
- # 用法:
25
- # bash scripts/drill-clean-machine.sh [--keep] [--dir <夹具根>] [--caps <单个能力>] [--timeout <秒>]
26
- # `--caps` 可给**多个**(逗号分隔):同批发布的包本就该一起装(跨包共用引擎文件必须同版);
27
- # E 步仍单独用全新树演示一次"两个包一起装"的完整判据(退出码 + 装机清单 + 注册表并集)。
28
- # 退出码:0 四跑与哨兵均符合预期 | 1 有不符合预期项(逐条点名)| 2 用法/环境错误
29
- set -uo pipefail
30
-
31
- REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
32
- KEEP=0
33
- FIX=""
34
- CAPS="full"
35
- GUARD_CAPS="full,invoice" # E 步:多包一起装(回归判据,见文档 §E)
36
- TIMEOUT=1800
37
- while [ $# -gt 0 ]; do
38
- case "$1" in
39
- --keep) KEEP=1; shift ;;
40
- --dir) FIX="$2"; shift 2 ;;
41
- --caps) CAPS="$2"; shift 2 ;;
42
- --timeout) TIMEOUT="$2"; shift 2 ;;
43
- -h|--help) sed -n '2,26p' "$0"; exit 0 ;;
44
- *) echo "✗ 未知参数:$1" >&2; exit 2 ;;
45
- esac
46
- done
47
- case "$CAPS" in
48
- *,*) : ;; # 多能力(逗号分隔)自 2026-10-09 起支持:一起装是**推荐**路径
49
- '') echo "✗ --caps 不能为空" >&2; exit 2 ;;
50
- esac
51
- [ -n "$FIX" ] || FIX="$(mktemp -d "${TMPDIR:-/tmp}/argent-drill.XXXXXX")"
52
- FIX="$(cd "$FIX" && pwd -P)"
53
- mkdir -p "$FIX/bin" "$FIX/home" "$FIX/packs"
54
-
55
- ONE="$REPO/scripts/install-one-shot.mjs"
56
- [ -f "$ONE" ] || { echo "✗ 缺 $ONE" >&2; exit 2; }
57
-
58
- FAIL=0
59
- note() { printf '%s\n' "$*"; }
60
- ok() { printf ' ✅ %s\n' "$*"; }
61
- bad() { printf ' ✗ %s\n' "$*"; FAIL=1; }
62
- warn() { printf ' ⚠️ %s\n' "$*"; }
63
- hdr() { printf '\n── %s\n' "$*"; }
64
-
65
- # ── 夹具 bin:只放 node / git / dsh(**不放** pnpm/npm/corepack)──────────────
66
- for t in node git dsh; do
67
- p="$(command -v "$t" 2>/dev/null || true)"
68
- [ -n "$p" ] && ln -sf "$p" "$FIX/bin/$t"
69
- done
70
- MINPATH="$FIX/bin:/usr/bin:/bin:/usr/sbin:/sbin"
71
- RUN=(env -i "HOME=$FIX/home" "PATH=$MINPATH" "TMPDIR=$FIX/tmp" \
72
- "ARGENT_REPO_URL=file://$REPO" # 在线面用**本地镜像**克隆(不是 whyshu 私服):零网络
73
- "ARGENT_SKIP_MODEL_PROBE=1") # 联网凭证探针在夹具里没有 Key ⇒ 用安装器既有逃逸阀(它会打警告)
74
- mkdir -p "$FIX/tmp"
75
-
76
- note "═══════════════════════════════════════════════════"
77
- note " 干净机器演练夹具:$FIX"
78
- note " HOME=$FIX/home(隔离)| PATH=$MINPATH"
79
- note " node=$(node --version 2>/dev/null)|git=$(git --version 2>/dev/null | awk '{print $3}')|dsh=$(command -v dsh || echo 无)"
80
- note " ⛔ 包管理器不在 PATH(演练刻意不跑 pnpm)"
81
- note "═══════════════════════════════════════════════════"
82
-
83
- # ── 哨兵(真实目录)────────────────────────────────────────────────────────
84
- # 立场:演练**只读**真实目录。哨兵盯"本次演练最可能误写的面",逐条 sha 比对。
85
- # 刻意**不**盯会话/日志(常驻 dsh web 在写,盘它必然恒红,属噪声不属判据)。
86
- # 真实数据目录:**只调内核的唯一判据**(`core/lib/argent-datadir.mjs#resolveDataDir()`),
87
- # 本脚本里**不写第二份**(`ARGENT_DATA_DIR`/`DSH_HOME` 一律清掉 ⇒ 解析出的就是"本机默认位置")。
88
- # 为什么哨兵也必须这样:门禁 scripts/tests/argent-datadir-parity.test.mjs【4】对 .sh 里的
89
- # `$HOME/.dsh/argent` 式硬编码判据**判红**(2026-10-01 桌面端事故形态)。
90
- # 用 `env -i` 清掉整份环境(**不点名任何**实例级开关变量 —— 点名它本身就是"第二份三级链",
91
- # 会被同一条门禁的 `ARGENT_DATA_DIR` 规则判红)。
92
- REAL_DATA_DIR="$(env -i "HOME=$HOME" "PATH=$PATH" node -e \
93
- 'import(process.argv[1]).then(m=>process.stdout.write(m.resolveDataDir().dir))' \
94
- "$REPO/core/lib/argent-datadir.mjs" 2>/dev/null)"
95
- [ -n "$REAL_DATA_DIR" ] || { echo "✗ 解析不出真实数据目录(缺 core/lib/argent-datadir.mjs?)—— 哨兵无法自证,演练中止" >&2; exit 2; }
96
-
97
- sentinel() {
98
- local out="$1"
99
- {
100
- echo "# $(date -u +%FT%TZ) 真实目录哨兵(演练前后必须逐字节一致)|数据目录=${REAL_DATA_DIR}"
101
- for f in "$REAL_DATA_DIR/install.json" "$REAL_DATA_DIR/license.json" \
102
- "$REAL_DATA_DIR/license.secret" "$REAL_DATA_DIR/skin.json" \
103
- "$REAL_DATA_DIR/gateway.json" "$REAL_DATA_DIR/roles.json" \
104
- "$HOME/.dsh/settings.yaml" "$HOME/.dsh/.credentials.yaml" \
105
- "$HOME/.dsh/profiles/web/package.json" "$HOME/.dsh/profiles/web/cordis.patch.yml"; do
106
- if [ -f "$f" ]; then printf '%s %s\n' "$(shasum -a 256 "$f" | cut -c1-16)" "${f/#$HOME/~}"; else printf 'MISSING %s\n' "${f/#$HOME/~}"; fi
107
- done
108
- for d in "$HOME/.dsh/skills" "$HOME/.dsh/.agent-presets"; do
109
- if [ -d "$d" ]; then
110
- printf 'DIR %s files=%s hash=%s\n' "${d/#$HOME/~}" \
111
- "$(find "$d" -type f | wc -l | tr -d ' ')" \
112
- "$(find "$d" -type f | LC_ALL=C sort | xargs shasum -a 256 2>/dev/null | shasum -a 256 | cut -c1-16)"
113
- else printf 'DIR-MISSING %s\n' "${d/#$HOME/~}"; fi
114
- done
115
- if [ -d "$HOME/.argent-dsh" ]; then
116
- printf 'ARGENT-DSH head=%s dirty=%s files=%s\n' \
117
- "$(git -C "$HOME/.argent-dsh" rev-parse --short HEAD 2>/dev/null || echo 非git)" \
118
- "$(git -C "$HOME/.argent-dsh" status --porcelain 2>/dev/null | wc -l | tr -d ' ')" \
119
- "$(find "$HOME/.argent-dsh" -type f -not -path '*/.git/*' | wc -l | tr -d ' ')"
120
- else printf 'ARGENT-DSH MISSING\n'; fi
121
- } > "$out" 2>/dev/null
122
- }
123
- sentinel "$FIX/sentinel-before.txt"
124
- node "$REPO/scripts/check-test-env-safety.mjs" --snapshot "$FIX/sentinel-datadir-before.json" >/dev/null 2>&1 || true
125
- note ""
126
- note "哨兵已拍:$FIX/sentinel-before.txt(+ 真实数据目录三层快照)"
127
-
128
- # ── 离线包(能力来源;不依赖克隆)───────────────────────────────────────────
129
- # 演练主线可多包(`--caps a,b`):自 2026-10-09 起"一起装"是支持路径(E 步另有显式判据)。
130
- BUILD_CAPS="$CAPS,$GUARD_CAPS" # 去重后逐个构建(重复目录会撞 --force 守卫)
131
- hdr "构建离线能力包(主线 --caps ${CAPS};E 步多包 ${GUARD_CAPS})"
132
- PACKARGS=""
133
- GUARDPACKARGS=""
134
- OLDIFS="$IFS"; IFS=','
135
- BUILT=","
136
- for cap in $BUILD_CAPS; do
137
- IFS="$OLDIFS"
138
- case "$BUILT" in *",$cap,"*) IFS=','; continue ;; esac
139
- if [ -n "$cap" ]; then
140
- if python3 "$REPO/scripts/capability_pack.py" --build "$cap" --out-dir "$FIX/packs/$cap" >"$FIX/packs/$cap.build.log" 2>&1; then
141
- ok "$cap → $FIX/packs/${cap}($(tail -1 "$FIX/packs/$cap.build.log" | sed 's/^ *//'))"
142
- BUILT="$BUILT$cap,"
143
- else
144
- bad "$cap 打包失败,见 $FIX/packs/$cap.build.log"; tail -5 "$FIX/packs/$cap.build.log"
145
- fi
146
- fi
147
- IFS=','
148
- done
149
- IFS="$OLDIFS"
150
- IFS=','
151
- for cap in $CAPS; do
152
- IFS="$OLDIFS"; [ -n "$cap" ] || { IFS=','; continue; }
153
- if [ -z "$PACKARGS" ]; then PACKARGS="$FIX/packs/$cap"; else PACKARGS="$PACKARGS,$FIX/packs/$cap"; fi
154
- IFS=','
155
- done
156
- for cap in $GUARD_CAPS; do
157
- IFS="$OLDIFS"; [ -n "$cap" ] || { IFS=','; continue; }
158
- if [ -z "$GUARDPACKARGS" ]; then GUARDPACKARGS="$FIX/packs/$cap"; else GUARDPACKARGS="$GUARDPACKARGS,$FIX/packs/$cap"; fi
159
- IFS=','
160
- done
161
- IFS="$OLDIFS"
162
- [ "$FAIL" = 0 ] || { echo "✗ 打包失败,演练中止"; exit 1; }
163
-
164
- one() { # one <标签> <期望退出码> <参数…>
165
- local label="$1" want="$2"; shift 2
166
- local t0 t1
167
- t0=$(date +%s)
168
- "${RUN[@]}" node "$ONE" "$@" >"$FIX/run-$label.out" 2>&1
169
- local rc=$?
170
- t1=$(date +%s)
171
- if [ "$rc" = "$want" ]; then ok "$label 退出码 ${rc}(期望 ${want},耗时 $((t1-t0))s)"
172
- else bad "$label 退出码 ${rc}(期望 ${want})—— 见 $FIX/run-$label.out"; tail -25 "$FIX/run-$label.out"; fi
173
- return 0
174
- }
175
-
176
- BASE=(--tree "$FIX/home/.argent-dsh" --dsh-home "$FIX/home/.dsh" --from-packs "$PACKARGS" --timeout "$TIMEOUT")
177
-
178
- # ── A. dry-run ─────────────────────────────────────────────────────────────
179
- hdr "A. dry-run(默认;必须零写入)"
180
- one A 0 "${BASE[@]}" --report "$FIX/report-A.json"
181
- if [ -e "$FIX/home/.dsh" ] || [ -e "$FIX/home/.argent-dsh" ]; then
182
- bad "dry-run 竟然写了夹具($FIX/home 下出现 .dsh/.argent-dsh)"
183
- else ok "dry-run 零写入(夹具 home 下没有 .dsh / .argent-dsh)"; fi
184
-
185
- # ── B. --apply(无许可)────────────────────────────────────────────────────
186
- hdr "B. --apply(无 --license-file ⇒ 许可段 skipped,不算失败)"
187
- one B 0 "${BASE[@]}" --apply --report "$FIX/report-B.json"
188
- [ -f "$FIX/home/.dsh/argent/install.json" ] && ok "装机清单在位:$FIX/home/.dsh/argent/install.json" \
189
- || bad "装机清单不在位(--from-packs 装配判据)"
190
- [ -d "$FIX/home/.dsh/skills" ] && ok "技能已同步到夹具:$FIX/home/.dsh/skills($(find "$FIX/home/.dsh/skills" -maxdepth 1 -mindepth 1 | wc -l | tr -d ' ') 项)" \
191
- || bad "技能目录没生成(技能同步段判据)"
192
- TREE_HASH_B="$( { [ -d "$FIX/home/.argent-dsh" ] && find "$FIX/home/.argent-dsh" -type f; } | LC_ALL=C sort | xargs shasum -a 256 2>/dev/null | shasum -a 256 | cut -c1-16)"
193
-
194
- # ── C. 重跑(幂等)─────────────────────────────────────────────────────────
195
- hdr "C. 重跑 --apply(幂等:逐段结论必须与 B 一致)"
196
- one C 0 "${BASE[@]}" --apply --report "$FIX/report-C.json"
197
- python3 - "$FIX/report-B.json" "$FIX/report-C.json" <<'PY' && ok "B/C 逐段状态一致(幂等)" || bad "B/C 逐段状态不一致(幂等被破)"
198
- import json,sys
199
- b=json.load(open(sys.argv[1])); c=json.load(open(sys.argv[2]))
200
- sb={s['id']:s['status'] for s in b['steps']}; sc={s['id']:s['status'] for s in c['steps']}
201
- if sb==sc: print(' B/C 状态:',sb)
202
- else:
203
- print(' B:',sb); print(' C:',sc); sys.exit(1)
204
- PY
205
- TREE_HASH_C="$( { [ -d "$FIX/home/.argent-dsh" ] && find "$FIX/home/.argent-dsh" -type f; } | LC_ALL=C sort | xargs shasum -a 256 2>/dev/null | shasum -a 256 | cut -c1-16)"
206
- [ "$TREE_HASH_B" = "$TREE_HASH_C" ] && ok "重跑后目标树逐字节不变(${TREE_HASH_C})" \
207
- || warn "重跑后目标树有变化($TREE_HASH_B → ${TREE_HASH_C})—— 逐条比对是 churn 还是缺陷"
208
-
209
- # ── D. 只跑许可段(夹具许可)───────────────────────────────────────────────
210
- hdr "D. --only license --apply(夹具密钥自签的许可;验的是「许可段能不能真装」)"
211
- SECRET="$FIX/fixture-license-secret"
212
- printf '%s' 'drill-fixture-secret-not-a-real-key' > "$SECRET"; chmod 600 "$SECRET"
213
- if "${RUN[@]}" node "$REPO/scripts/self-sign-license.mjs" --user drill-fixture --secret-file "$SECRET" \
214
- --out "$FIX/fixture-license.json" --sign-v2 --device-from-local --cap full --seats 1 --days 30 \
215
- >"$FIX/selfsign.out" 2>&1; then
216
- ok "夹具许可已自签:$FIX/fixture-license.json(夹具密钥,⛔ 不是任何真实环境的密钥)"
217
- else bad "夹具许可自签失败,见 $FIX/selfsign.out"; tail -5 "$FIX/selfsign.out"; fi
218
- one D 0 --tree "$FIX/home/.argent-dsh" --dsh-home "$FIX/home/.dsh" \
219
- --license-file "$FIX/fixture-license.json" --license-secret-file "$SECRET" \
220
- --only license --apply --report "$FIX/report-D.json"
221
- [ -f "$FIX/home/.dsh/argent/license.json" ] && ok "许可已落到夹具数据目录(真实数据目录不动)" \
222
- || bad "许可没落盘(许可段判据)"
223
-
224
- # ── F. 在线装配面(本地镜像克隆;零网络;无包管理器 ⇒ 走安装器既有"直拷"回退)──────
225
- hdr "F. 在线装配面(ARGENT_REPO_URL=file://<仓库>,零网络;无包管理器 ⇒ 直拷回退)"
226
- note " ⛔ 全程不跑 pnpm:夹具 PATH 里没有 pnpm/npm/corepack ⇒ 安装器走它自己的回退分支。"
227
- note " ⚠️ 该回退曾**不可达**(ensurePnpm 的 \`npm install -g pnpm\` 不在 try 里 ⇒ spawn npm ENOENT 未捕获崩),"
228
- note " 2026-10-05 已修(install-argent.mjs,见文档「失败与修法」)。"
229
- one F 0 --tree "$FIX/home-f/.argent-dsh" --dsh-home "$FIX/home-f/.dsh" --profile web \
230
- --skip-provider --no-venv --apply --report "$FIX/report-F.json"
231
- [ -f "$FIX/home-f/.dsh/profiles/web/cordis.patch.yml" ] && ok "在线面产物:profile patch 在位(profiles/web/cordis.patch.yml)" \
232
- || bad "在线面产物缺失:profiles/web/cordis.patch.yml"
233
- [ -d "$FIX/home-f/.dsh/.agent-presets" ] && ok "在线面产物:角色已落位(.agent-presets/$(ls "$FIX/home-f/.dsh/.agent-presets" | head -3 | tr '\n' ' '))" \
234
- || warn "在线面没落 preset(机制可能不是目录式;见 report-F.json)"
235
- [ -d "$FIX/home-f/.argent-dsh/plugins" ] && ok "在线面克隆到夹具树:$FIX/home-f/.argent-dsh($(find "$FIX/home-f/.argent-dsh" -maxdepth 1 -mindepth 1 | wc -l | tr -d ' ') 个顶层条目)" \
236
- || bad "在线面没克隆出树"
237
-
238
- # ── E. 多包一起装(2026-10-09 起**支持**,本步验证它真能装成)────────────────
239
- hdr "E. 多包一起装 --from-packs ${GUARD_CAPS}(期望退出码 0;注册表 = 两份投影的并集)"
240
- # ⚠ 必须用**全新**的树/home:B 已把主线能力铺好,复用会让安装器走"升级"路径,测不到"首次多包装"。
241
- one E 0 --tree "$FIX/home-e/.argent-dsh" --dsh-home "$FIX/home-e/.dsh" --profile web \
242
- --from-packs "$GUARDPACKARGS" --apply --report "$FIX/report-E.json"
243
- # 判据三条:① 不再报"多包校验失败";② 装机清单里两个能力都在且 integrity=verified;③ 盘上注册表是并集
244
- if grep -q "多包校验失败" "$FIX/run-E.out"; then
245
- bad "多包一起装仍被拦下(见 $FIX/run-E.out);拦下原因:$(grep -m1 '多包校验失败' "$FIX/run-E.out" | sed 's/^ *· *//')"
246
- else
247
- ok "不再报「多包校验失败」(注册表投影已被判据豁免:计划阶段与落盘阶段同一个 isRegistryProjection())"
248
- fi
249
- python3 - "$FIX/home-e/.dsh/argent/install.json" "$FIX/home-e/.argent-dsh/scripts/flow-requirements.json" <<'PY'
250
- import json, os, sys
251
- ij, reg = sys.argv[1], sys.argv[2]
252
- if not os.path.exists(ij):
253
- print(f' ✗ 装机清单不在位:{ij}'); sys.exit(1)
254
- doc = json.load(open(ij))
255
- caps = {c['id']: c.get('integrity') for c in doc.get('capabilities', [])}
256
- want = {'full', 'invoice'}
257
- if want <= set(caps):
258
- print(f" ✅ 装机清单两个能力都在({', '.join(f'{k}={v}' for k, v in sorted(caps.items()))})")
259
- else:
260
- print(f" ✗ 装机清单缺能力:{sorted(want - set(caps))}(实际 {sorted(caps)})"); sys.exit(1)
261
- if all(caps[k] == 'verified' for k in want):
262
- print(' ✅ 两个能力的 integrity 都是 verified')
263
- else:
264
- print(f" ✗ integrity 不是全 verified:{caps}"); sys.exit(1)
265
- if not os.path.exists(reg):
266
- print(f' ✗ 盘上注册表不在位:{reg}'); sys.exit(1)
267
- d = json.load(open(reg))
268
- c = sorted(d.get('capabilities') or {})
269
- if {'full', 'invoice'} <= set(c):
270
- print(f" ✅ 盘上注册表 = 两份投影的并集(capabilities:{', '.join(c)})")
271
- else:
272
- print(f" ✗ 注册表缺能力条目(实际 {c})—— 并集没写成"); sys.exit(1)
273
- PY
274
- [ "$?" = 0 ] || bad "E 步判据未过(装机清单 / 注册表并集,见上)"
275
-
276
- # ── 哨兵比对 ───────────────────────────────────────────────────────────────
277
- hdr "哨兵:真实目录零变化"
278
- sentinel "$FIX/sentinel-after.txt"
279
- tail -n +2 "$FIX/sentinel-before.txt" > "$FIX/sentinel-before.body"
280
- tail -n +2 "$FIX/sentinel-after.txt" > "$FIX/sentinel-after.body"
281
- if diff -u "$FIX/sentinel-before.body" "$FIX/sentinel-after.body" > "$FIX/sentinel.diff" 2>&1; then
282
- ok "真实目录哨兵逐字节一致($FIX/sentinel-before.txt)"
283
- else
284
- bad "真实目录哨兵有变化(本次演练本应零写入):"; cat "$FIX/sentinel.diff"
285
- fi
286
- if node "$REPO/scripts/check-test-env-safety.mjs" --verify "$FIX/sentinel-datadir-before.json" >"$FIX/sentinel-datadir.out" 2>&1; then
287
- ok "真实数据目录三层判据(关键文件/新增文件/配置内容)无本次演练引起的改动"
288
- else
289
- warn "真实数据目录有变化(可能是常驻服务的正常写入;逐条判断):"; tail -12 "$FIX/sentinel-datadir.out"
290
- fi
291
-
292
- # ── 汇总 ───────────────────────────────────────────────────────────────────
293
- hdr "汇总"
294
- python3 - "$FIX" <<'PY'
295
- import json,os,sys
296
- fix=sys.argv[1]
297
- for tag in ('A','B','C','D','E','F'):
298
- p=os.path.join(fix,f'report-{tag}.json')
299
- if not os.path.exists(p): print(f' {tag}: (无报告)'); continue
300
- d=json.load(open(p))
301
- st={s['id']:s['status'] for s in d['steps']}
302
- print(f" {tag}: ok={d['ok']} mode={d['mode']} 耗时={d['elapsedSeconds']}s 逐段={st}")
303
- print(f" 装配={d['segments']['install']}")
304
- print(f" 技能={d['segments']['skills']}")
305
- print(f" 许可={d['segments']['license']}")
306
- print(f" 自检={d['selfCheck']}")
307
- PY
308
- note ""
309
- if [ "$FAIL" = 0 ]; then
310
- note "✅ 演练全部符合预期(夹具:${FIX})"
311
- else
312
- note "✗ 演练有不符合预期项(夹具:${FIX})—— 逐条见上"
313
- fi
314
- if [ "$KEEP" = 0 ] && [ "$FAIL" = 0 ]; then rm -rf "$FIX"; note "(夹具已清理;要留证据加 --keep)"; else note "(夹具保留:${FIX})"; fi
315
- exit "$FAIL"