@whyshu/argent-engine 0.7.2 → 0.7.4

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 (57) hide show
  1. package/MANIFEST.json +46 -40
  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 +34 -40
  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/drill-clean-room.sh +45 -0
  15. package/engine/scripts/drill-handover.sh +4 -1
  16. package/engine/scripts/dsh-upgrade.mjs +16 -20
  17. package/engine/scripts/flow-requirements.json +5 -7
  18. package/engine/scripts/flow-timers.sh +1 -1
  19. package/engine/scripts/flow-trigger.sh +1 -1
  20. package/engine/scripts/gen-preset-rows.mjs +20 -0
  21. package/engine/scripts/handover-sheet.mjs +9 -1
  22. package/engine/scripts/invoice_stageb.py +20 -1
  23. package/engine/scripts/lib/agent-install.mjs +10 -1
  24. package/engine/scripts/lib/daily-report-core.mjs +124 -16
  25. package/engine/scripts/lib/dsh-versions.mjs +17 -1
  26. package/engine/scripts/lib/ml-ads-core.mjs +204 -0
  27. package/engine/scripts/lib/plugin-packages.mjs +1 -0
  28. package/engine/scripts/ml-ads.mjs +181 -0
  29. package/engine/scripts/ml-oauth-setup.mjs +1 -1
  30. package/engine/scripts/orchestrator.py +91 -10
  31. package/engine/scripts/p2-report-run.mjs +102 -0
  32. package/engine/scripts/patches/dsh-session--append-message-shape.patch +34 -9
  33. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +32 -8
  34. package/engine/scripts/patches/retire-check.sh +0 -4
  35. package/engine/scripts/patches/verify-hardening.mjs +4 -1
  36. package/engine/scripts/patches/verify-turn-outside-replace.mjs +64 -0
  37. package/engine/scripts/poll-fulfillment.sh +1 -1
  38. package/engine/scripts/profile-doctor.mjs +1 -1
  39. package/engine/scripts/python-runtime.sh +16 -4
  40. package/engine/scripts/release-argent.sh +8 -7
  41. package/engine/scripts/session-handoff.mjs +111 -0
  42. package/engine/scripts/session-load-check.mjs +105 -0
  43. package/engine/scripts/sync-scripts.sh +1 -1
  44. package/engine/scripts/xzh-browser.sh +37 -0
  45. package/engine/scripts/xzh_browser.py +49 -0
  46. package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +30 -0
  47. package/engine/skills/ml-full-appointment/SKILL.md +98 -0
  48. package/engine/version.mjs +1 -1
  49. package/package.json +1 -1
  50. package/engine/scripts/drill-clean-machine.sh +0 -315
  51. package/engine/scripts/install-internal-role.sh +0 -190
  52. package/engine/scripts/install-one-shot.mjs +0 -665
  53. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +0 -297
  54. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +0 -11
  55. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +0 -39
  56. package/engine/scripts/patches/verify-bundled-migrators.mjs +0 -79
  57. package/engine/scripts/patches/verify-transport-diagnostics.mjs +0 -227
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ml-ads.mjs —— Mercado Ads(Product Ads)**只读**效果报告 CLI。
4
+ *
5
+ * ## 干什么
6
+ * 用与日报同一条授权链(`~/.dsh/argent/ml-api-stores.json`)逐店拉广告数据,输出:
7
+ * 花费 / 广告销售额 / ROAS / ACoS / 点击 / CTR / CPC / 广告单量,
8
+ * 外加**告警**(ACoS 超标、烧钱零转化、ROAS 过低)和活动级下钻。
9
+ *
10
+ * ## 用法
11
+ * node scripts/ml-ads.mjs # 全部已授权店铺 · 近 7 个完整自然日
12
+ * node scripts/ml-ads.mjs --store 1店 # 店铺子串过滤
13
+ * node scripts/ml-ads.mjs --days 30 # 回溯天数(MELI 上限 90)
14
+ * node scripts/ml-ads.mjs --to 2026-10-16 # 指定结束日(站点本地)
15
+ * node scripts/ml-ads.mjs --detail # 附「花钱最多 / ACoS 超标」活动
16
+ * node scripts/ml-ads.mjs --json # 结构化输出(stdout 纯 JSON)
17
+ * node scripts/ml-ads.mjs --lines # 只输出中文卡片行(给日报/角色回复用)
18
+ * node scripts/ml-ads.mjs --list # 只列每店广告主,不拉活动
19
+ * node scripts/ml-ads.mjs --acos-bad 30 --min-spend 200 # 告警阈值
20
+ *
21
+ * ## 只读纪律
22
+ * · 只发 GET(`/advertising/...`);⛔ 不改预算/暂停/调价,也不写任何客户数据;
23
+ * · 唯一写盘 = token 轮换回写(MELI refresh_token 是轮换制,不写回下次必 429):
24
+ * 原子写(tmp + rename)、保持 600;
25
+ * · API 基址:`ML_API_BASE` → 默认平台地址(引擎 lib 里⛔不含域名,只在本 CLI 注入)。
26
+ */
27
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, chmodSync } from 'node:fs'
28
+ import { join, dirname } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { resolveDataDir } from './lib/argent-datadir.mjs'
31
+ import {
32
+ loadEnvFile, refreshStoreToken, apiGet as coreApiGet,
33
+ SITE_TZ, SITE_CURRENCY, fallbackSite, startOfLocalDayUTC, localDateStr, matchStores,
34
+ } from './lib/daily-report-core.mjs'
35
+ import {
36
+ fetchAdsReport, classifyAdsAlerts, adsCardLines, fmtMoney, ADS_ALERT_DEFAULTS,
37
+ } from './lib/ml-ads-core.mjs'
38
+
39
+ const API_BASE = () => process.env.ML_API_BASE || 'https://api.mercadolibre.com'
40
+
41
+ const args = process.argv.slice(2)
42
+ const argVal = (n) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] ? args[i + 1] : null }
43
+ const has = (n) => args.includes(n)
44
+
45
+ if (has('-h') || has('--help')) {
46
+ console.log(`Mercado Ads 只读报告
47
+
48
+ 用法: node scripts/ml-ads.mjs [选项]
49
+ --store <名或uid> 店铺过滤(名称子串 / uid 全等,与日报同一判据)
50
+ --days <n> 回溯天数,默认 7(MELI 上限 90)
51
+ --to <YYYY-MM-DD> 结束日(站点本地),默认「昨天」
52
+ --detail 附活动级下钻(花钱最多 + ACoS 超标)
53
+ --list 只列出每店广告主(1 请求/店)
54
+ --json 结构化 JSON(stdout)
55
+ --lines 中文卡片行(给日报分区/角色回复复用)
56
+ --acos-warn <n> ACoS 黄色阈值,默认 ${ADS_ALERT_DEFAULTS.acosWarn}
57
+ --acos-bad <n> ACoS 红色阈值,默认 ${ADS_ALERT_DEFAULTS.acosBad}
58
+ --min-spend <n> 告警最小花费(本币),默认 ${ADS_ALERT_DEFAULTS.minSpend}
59
+ --env <path> daily-report.env 路径
60
+ 退出码: 0 正常 | 1 用法/取数失败 | 2 全部店铺失败`)
61
+ process.exit(0)
62
+ }
63
+
64
+ const DATA_DIR = resolveDataDir({ env: process.env }).dir
65
+ const ENV_PATH = argVal('--env') || join(DATA_DIR, 'daily-report.env')
66
+ const STORES_PATH = process.env.ML_API_STORES_JSON || join(DATA_DIR, 'ml-api-stores.json')
67
+ const env = loadEnvFile(ENV_PATH)
68
+ const doc = existsSync(STORES_PATH) ? JSON.parse(readFileSync(STORES_PATH, 'utf8')) : { stores: [] }
69
+ const stores = doc.stores || []
70
+
71
+ const persistStores = () => {
72
+ try {
73
+ mkdirSync(dirname(STORES_PATH), { recursive: true })
74
+ const tmp = `${STORES_PATH}.tmp`
75
+ writeFileSync(tmp, JSON.stringify({ stores }, null, 2) + '\n', { mode: 0o600 })
76
+ renameSync(tmp, STORES_PATH)
77
+ chmodSync(STORES_PATH, 0o600)
78
+ } catch (e) { console.error(`✗ token 写回失败:${e.message}`) }
79
+ }
80
+ const apiGet = (path, token) => coreApiGet(path, token, { base: API_BASE() })
81
+
82
+ const alertOpts = {
83
+ acosWarn: Number(argVal('--acos-warn') || ADS_ALERT_DEFAULTS.acosWarn),
84
+ acosBad: Number(argVal('--acos-bad') || ADS_ALERT_DEFAULTS.acosBad),
85
+ minSpend: Number(argVal('--min-spend') || ADS_ALERT_DEFAULTS.minSpend),
86
+ minRoas: ADS_ALERT_DEFAULTS.minRoas,
87
+ }
88
+
89
+ const FLAG_JSON = has('--json')
90
+ const FLAG_LINES = has('--lines')
91
+ const FLAG_DETAIL = has('--detail')
92
+ const FLAG_LIST = has('--list')
93
+ const days = Math.max(1, Math.min(90, Number(argVal('--days') || 7)))
94
+ const toArg = argVal('--to')
95
+
96
+ const now = new Date()
97
+ const picked = matchStores(stores, (argVal('--store') || '').split(',').map((x) => x.trim()).filter(Boolean))
98
+ if (!picked.length) {
99
+ console.error(`✗ 没有匹配到店铺(已授权 ${stores.length} 家)`)
100
+ process.exit(1)
101
+ }
102
+
103
+ const results = []
104
+ for (const store of picked) {
105
+ const site = fallbackSite(store.site)
106
+ const tz = SITE_TZ[site]
107
+ const todayStart = startOfLocalDayUTC(tz, now)
108
+ const toMs = toArg ? startOfLocalDayUTC(tz, new Date(`${toArg}T12:00:00Z`)) : todayStart - 86400000
109
+ const fromMs = toMs - (days - 1) * 86400000
110
+ const dateFrom = localDateStr(tz, new Date(fromMs))
111
+ const dateTo = localDateStr(tz, new Date(toMs))
112
+ const row = { store: store.name, site, uid: store.user_id, currency: SITE_CURRENCY[site] || '', dateFrom, dateTo }
113
+ try {
114
+ const token = await refreshStoreToken(env, store, { onRotate: persistStores })
115
+ if (FLAG_LIST) {
116
+ const adv = await apiGet('/advertising/advertisers?product_id=PADS', token)
117
+ row.advertisers = (adv.advertisers || []).map((a) => ({ advertiser_id: a.advertiser_id, site: a.site_id, name: a.advertiser_name }))
118
+ results.push(row)
119
+ continue
120
+ }
121
+ const report = await fetchAdsReport({ apiGet, token, site, dateFrom, dateTo })
122
+ row.report = report
123
+ row.alerts = classifyAdsAlerts(report.totals, alertOpts)
124
+ results.push(row)
125
+ } catch (e) {
126
+ row.error = String(e.message || e)
127
+ results.push(row)
128
+ }
129
+ }
130
+
131
+ // ── 输出 ───────────────────────────────────────────────────
132
+ const ok = results.filter((r) => !r.error)
133
+ if (!ok.length) {
134
+ if (FLAG_JSON) console.log(JSON.stringify({ results }, null, 2))
135
+ else for (const r of results) console.error(`✗ ${r.store}:${r.error}`)
136
+ process.exit(2)
137
+ }
138
+ if (FLAG_JSON) { console.log(JSON.stringify({ generatedAt: now.toISOString(), days, results }, null, 2)); process.exit(0) }
139
+
140
+ if (FLAG_LINES) {
141
+ for (const r of ok) {
142
+ const lines = adsCardLines(r.report, { currency: r.currency, alertOpts })
143
+ console.log(`### ${r.store}`)
144
+ console.log(lines.join('\n'))
145
+ }
146
+ process.exit(0)
147
+ }
148
+
149
+ if (FLAG_LIST) {
150
+ for (const r of results) {
151
+ if (r.error) { console.log(`${r.store}: ✗ ${r.error}`); continue }
152
+ console.log(`${r.store}(${r.site}):` + (r.advertisers.length ? r.advertisers.map((a) => `${a.advertiser_id}(${a.site})`).join(', ') : '无广告主'))
153
+ }
154
+ process.exit(0)
155
+ }
156
+
157
+ const hdr = ['店铺', '站点', '花费', '销售额', 'ROAS', 'ACoS', '点击', 'CTR', '广告单量', '活动']
158
+ let text = `## 📣 Mercado Ads · ${ok[0].dateFrom} ~ ${ok[0].dateTo}(近 ${days} 天)\n\n`
159
+ text += '| ' + hdr.join(' | ') + ' |\n|' + hdr.map(() => '---').join('|') + '|\n'
160
+ const allAlerts = []
161
+ for (const r of results) {
162
+ if (r.error) { text += `| ${r.store} | ${r.site} | — | — | — | — | — | — | — | ✗ ${r.error.slice(0, 40)} |\n`; continue }
163
+ const t = r.report.totals
164
+ const c = r.currency ? r.currency + ' ' : ''
165
+ text += `| ${r.store} | ${r.site} | ${c}${fmtMoney(t.cost)} | ${c}${fmtMoney(t.amount)} | ${t.roas} | ${t.acos}% | ${t.clicks} | ${t.ctr}% | ${t.units} | ${t.campaigns} |\n`
166
+ for (const a of r.alerts) allAlerts.push({ store: r.store, ...a })
167
+ if (FLAG_DETAIL) {
168
+ for (const a of r.report.advertisers) {
169
+ const top = [...(a.topCampaigns.top || [])]
170
+ for (const cp of top) text += `| ↳ ${r.store}·${cp.name.slice(0, 24)} | ${a.advertiserId} | ${c}${fmtMoney(cp.cost)} | ${c}${fmtMoney(cp.amount)} | ${cp.roas} | ${cp.acos}% | ${cp.clicks} | — | ${cp.units} | — |\n`
171
+ }
172
+ }
173
+ }
174
+ text += '\n'
175
+ if (allAlerts.length) {
176
+ text += '### ⚠️ 告警\n'
177
+ for (const a of allAlerts) text += `- ${a.level === 'red' ? '🔴' : '🟡'} **${a.store}**:${a.text}\n`
178
+ } else {
179
+ text += '✅ 无告警(ACoS 与 ROAS 均在阈值内)\n'
180
+ }
181
+ console.log(text)
@@ -49,7 +49,7 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
49
49
  // 其他站点域名实测被拦(403),后续逐个站点验证后更新此表。
50
50
  const AUTH_HOSTS = {
51
51
  MLM: 'auth.mercadolibre.com.mx',
52
- MLB: 'auth.mercadolibre.com.br',
52
+ MLB: 'auth.mercadolivre.com.br', // 2026-10-17 实测修正:巴西站点域名是 mercadolivre(auth.mercadolibre.com.br 无 DNS)
53
53
  MLA: 'auth.mercadolibre.com.ar',
54
54
  MLC: 'auth.mercadolibre.cl',
55
55
  MCO: 'auth.mercadolibre.com.co',
@@ -7349,6 +7349,16 @@ class Orchestrator:
7349
7349
  i = j + 1
7350
7350
  return ",".join(out)
7351
7351
 
7352
+ def _write_status_done(self, record_id: str) -> None:
7353
+ """**唯一入口**:把 `FULL状态` 置为「已完成」。
7354
+
7355
+ 2026-10-16:原先只有 `_finalize_ticket_complete`(引擎自己跑完)能置位;现在
7356
+ 「运营手动完成(hub-v2 实测)」也要置位(用户口径「都要」)⇒ 抽出本入口,两条路径
7357
+ **都走这里**,保持"表状态只有一个写点"的不变量(回归见 ticket-completion-criteria /
7358
+ step-progress-realtime 的 AST 断言)。
7359
+ """
7360
+ self.feishu.update_status(record_id, "done")
7361
+
7352
7362
  def _finalize_ticket_complete(self, *, label: str = "全部完成",
7353
7363
  notify: bool = True) -> bool:
7354
7364
  """**唯一**的「整票完成态」写入入口(`FULL状态=已完成` + `当前步骤` + 完成卡)。
@@ -7372,7 +7382,7 @@ class Orchestrator:
7372
7382
  "field": "ticket-complete", "value": label[:120],
7373
7383
  "why": "premature-completion-ticket", "missing_steps": missing})
7374
7384
  return False
7375
- self.feishu.update_status(self.state.record_id, "done")
7385
+ self._write_status_done(self.state.record_id)
7376
7386
  self.feishu.update_step_progress(self.state.record_id, label, force=True, done=True,
7377
7387
  ticket_attestation=self._ticket_attestation())
7378
7388
  self.feishu.set_confirm(self.state.record_id, False)
@@ -7761,11 +7771,11 @@ class Orchestrator:
7761
7771
  ## 写什么 / ⛔ 不写什么(用户口径)
7762
7772
  - **写**:`FULL备注` ← 「识别为运营手动完成(依据 hub-v2 实测):<逐项>」(同源同文,
7763
7773
  与 `--mode hub-status` 打印、与 `failures.jsonl#hub_status` 完全一致);
7764
- `当前步骤` ← 「已识别为运营手动完成(hub-v2 实测)」;`就绪=false`(消费掉拾取位)。
7765
- - **⛔ 不写**:`FULL状态` —— 「不得据此自动改写 FULL状态(那一步留给人/用户)」。所以
7766
- 这里**故意不调** `update_status`:表的最终状态由运营确认后自己置位。
7767
- ⚠️ 因此 `当前步骤` 也**不写「全部完成」**(那句只属于"引擎自己跑完"的路径,
7768
- 写了就会造成「表=完成、台账=blocked」的错位——今天已出现 2 次)。
7774
+ `当前步骤` ← **「全部完成」**(2026-10-16 用户口径:表口径与"引擎自己跑完"一致);`就绪=false`(消费掉拾取位)。
7775
+ **`FULL状态` ← 已完成**(2026-10-16 用户口径「都要」:认定即置位,否则票一直挂在"失败"里,
7776
+ 运营每轮巡检都要人工排除;判据仍是 hub-v2 三项全 True,比表里的旧状态权威)。
7777
+ - **仍不写**:不发「FULL 货件完成」卡、不写 `ticket_attestation`(卡与存证只属于引擎自己
7778
+ 跑完的路径);"谁完成的"这件事仍由 `FULL备注` 那句话交代。
7769
7779
 
7770
7780
  返回 `{"status": "already_completed", "identified_as": "manual-completion-by-hub", …}`
7771
7781
  ⇒ `_execute_steps` 见 `already_completed` 即停止后续步骤。
@@ -7773,13 +7783,15 @@ class Orchestrator:
7773
7783
  rid = self.state.record_id
7774
7784
  note = f"{self.HUB_MANUAL_NOTE}:" + ";".join(verdict.get("items") or [])
7775
7785
  self._log(f" 🧾 {self.HUB_MANUAL_NOTE}:{verdict.get('reason') or ''}"
7776
- f"(货件 #{self.state.shipment_id})—— 按用户口径**不改写 FULL状态**")
7786
+ f"(货件 #{self.state.shipment_id})—— 并置 FULL状态=已完成(2026-10-16 用户口径)")
7777
7787
  if rid and not getattr(self.feishu, "dry_run", False):
7778
7788
  try:
7779
7789
  self.feishu.set_note(rid, note[:900])
7780
- self.feishu.update_step_progress(rid, "已识别为运营手动完成(hub-v2 实测)",
7790
+ self.feishu.update_step_progress(rid, "全部完成",
7781
7791
  force=True)
7782
7792
  self.feishu.set_confirm(rid, False)
7793
+ # 2026-10-16 用户口径(「都要」):认定运营手动完成 ⇒ 一并置 FULL状态=已完成
7794
+ self._write_status_done(rid)
7783
7795
  except Exception as exc: # noqa: BLE001
7784
7796
  self._log(f" ⚠️ 写「运营手动完成」备注失败(不影响判定): {exc}")
7785
7797
  return {
@@ -7788,10 +7800,29 @@ class Orchestrator:
7788
7800
  "identified_as": "manual-completion-by-hub",
7789
7801
  "hub_note": note[:900],
7790
7802
  "hub_status": verdict,
7791
- "status_unchanged": True, # 显式标注:没有改写 FULL状态(下游据此别误判"已完成")
7803
+ "status_unchanged": False, # 已改写 FULL状态(2026-10-16 起)
7804
+ "status_written": "已完成", # 与 hub 判据同源;下游可据此确认表已置位
7792
7805
  }
7793
7806
 
7794
7807
  # ---- 本地失败诊断(failures.jsonl 索引)----
7808
+ async def _hub_manual_step7_fallback(self) -> bool:
7809
+ """步骤7 收口前先问 hub 真相:门户全完成 ⇒ 认定「运营手动完成」并返回 True。
7810
+
7811
+ KB-137(2026-10-16 现场 79301358):单步/异常路径会绕过前置「先问 hub 真相」判定,
7812
+ 于是"门户其实早已全完成(运营手工做完)"的票被判失败留在表里。补同一判据
7813
+ (唯一入口 `_hub_truth` + `_hub_verdict_of`),与前置检查同源同文;探针不可用 ⇒ False。
7814
+ """
7815
+ try:
7816
+ hub = await self._hub_truth()
7817
+ verdict = self._hub_verdict_of(hub)
7818
+ except Exception as exc: # noqa: BLE001(探针失败 ⇒ 沿用原判定)
7819
+ self._log(f" ⚠️ 步骤7 收口前的 hub 探针不可用(沿用原判定): {exc}")
7820
+ return False
7821
+ if verdict.get("operationally_complete"):
7822
+ self._hub_manual_complete(verdict)
7823
+ return True
7824
+ return False
7825
+
7795
7826
  def _current_log_file(self) -> str:
7796
7827
  """本次运行日志路径:优先 poll 脚本注入的 FULFILLMENT_LOG_FILE,兜底按店铺名推导。"""
7797
7828
  env_path = os.environ.get("FULFILLMENT_LOG_FILE", "").strip()
@@ -11019,6 +11050,20 @@ class Orchestrator:
11019
11050
  except Exception:
11020
11051
  return False
11021
11052
 
11053
+ async def _declara_progress(self) -> tuple[int, int]:
11054
+ """返回 (已勾选 checkbox, 总数) —— 区分「残留文案」与「声明确实没做完」。
11055
+
11056
+ KB-136(2026-10-16 现场 79301358):4 个箱子只声明了 2 个 ⇒ 「Descarga todas」永远不会
11057
+ 出现,而旧逻辑把点击前就存在的「Declara al menos」一律当**残留** ⇒ 干等 30s 后以
11058
+ "平台侧异常"收口(冤枉平台、运营也拿不到可执行信息)。读不到 ⇒ (-1,-1),不改变原判定。
11059
+ """
11060
+ try:
11061
+ raw = await self._generar_diag()
11062
+ d = json.loads(raw) if raw else {}
11063
+ return int(d.get("checkbox_checked") or 0), int(d.get("checkbox_total") or 0)
11064
+ except Exception: # noqa: BLE001(读不到 ⇒ 沿用原判定)
11065
+ return -1, -1
11066
+
11022
11067
  async def _generar_diag(self) -> str:
11023
11068
  """KB-128:采集 Generar/Descarga 按钮态 + checkbox 勾选态 + Declara 提示 + URL。
11024
11069
 
@@ -11233,6 +11278,33 @@ class Orchestrator:
11233
11278
  # ② 提示在**点击 Generar 之前就已存在** = 残留(页面流转留下的旧校验文案),
11234
11279
  # 不能当作"checkbox 未生效"的证据 ⇒ 不作为失败信号,继续等。
11235
11280
  if self._declara_hint_at_click:
11281
+ # KB-136:先看勾选进度 —— 不是残留而是**声明确实没做完**时,干等必然超时。
11282
+ _declared, _total = await self._declara_progress()
11283
+ _andes_ok = await self._andes_checked()
11284
+ # ⚠️ checkbox_total 含我们**故意跳过**的 #1(bultos)⇒ "勾选数 < 总数"本身不是声明
11285
+ # 未完成的证据(正常态就是 2/4)。必须再看 Andes 勾选态(#2/#3 是否到位)。
11286
+ if _total > 0 and _declared < _total and not _andes_ok:
11287
+ if retried >= 1:
11288
+ raise StepError(
11289
+ 7, "business",
11290
+ f"[declaration_incomplete] 箱型/箱数声明只完成 {_declared}/{_total}"
11291
+ f"(「Declara al menos」仍在)⇒ 下载按钮不会出现;需在该页面把剩下的箱子也声明完后重跑。"
11292
+ f"现场: {await self._generar_diag()}",
11293
+ recovery_attempted=["declara_progress_probe", "andes_checkbox_recheck"])
11294
+ retried += 1
11295
+ self._log(f" ⚠️ 「Declara al menos」为**真实未完成**(已声明 {_declared}/{_total})"
11296
+ f"⇒ 重勾 Andes checkbox 并重新触发 Generar(第 {retried}/2 次)")
11297
+ try:
11298
+ shot = await self._screenshot_on_error(7)
11299
+ if shot:
11300
+ self._log(f" 📸 重试现场截图(供识图判读): {shot}")
11301
+ except Exception:
11302
+ pass
11303
+ await self._check_andes_checkboxes()
11304
+ self._declara_hint_at_click = await self._has_declara_hint()
11305
+ await self.browser.click_with_fallback(7, "generate_btn", "Generar etiquetas", wait_after=5.0)
11306
+ waited = 0.0
11307
+ continue
11236
11308
  if not stale_logged:
11237
11309
  self._log(" ℹ️ 「Declara al menos」在点击 Generar **之前**就已存在"
11238
11310
  "(残留文案)⇒ 不作为失败信号,继续等待生成结果")
@@ -12277,7 +12349,16 @@ class Orchestrator:
12277
12349
  elif step == 6:
12278
12350
  await self.step6_labels(dry_run=dry_run)
12279
12351
  elif step == 7:
12280
- await self.step7_box_labels()
12352
+ try:
12353
+ await self.step7_box_labels()
12354
+ except StepError:
12355
+ # KB-137:收口前先问 hub 真相(门户可能早已完成,只是页面态残留)
12356
+ if await self._hub_manual_step7_fallback():
12357
+ return self._result("already_completed", None,
12358
+ {"step": 7, "type": "hub_manual_complete",
12359
+ "message": self.HUB_MANUAL_NOTE},
12360
+ step_summaries, dry_run)
12361
+ raise
12281
12362
  elif step == 8:
12282
12363
  await self.step8_cancel_appointment()
12283
12364
  # ── 需求 2:步骤**结束**(成功)再写一次(节流;文案与开跑不同 ⇒ 必写) ──
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * P2 执行面回写器(本机 → 服务端)—— 用 runner token 把一次执行结果写回数据中心。
4
+ *
5
+ * 只做三件事(都不含业务判定):
6
+ * 1) --create-card <title> 建服务端业务卡(可带 --preset)
7
+ * 2) --report 写 run(POST /runs)+ 结算卡状态(PATCH /cards/:id)
8
+ * 3) --verify 读回卡与 run,供自动核对
9
+ *
10
+ * 用法:
11
+ * node scripts/p2-report-run.mjs --state /tmp/argent-dev-local.json --create-card "美客多日报(试点)" --preset argent-ml-daily
12
+ * node scripts/p2-report-run.mjs --state ... --report --card-id <id> --preset argent-ml-daily --outcome succeeded --tokens 120 --summary "链路验证通过"
13
+ * node scripts/p2-report-run.mjs --state ... --verify --card-id <id>
14
+ *
15
+ * 状态文件(600)由 `whyshu-svr/scripts/dev-local.mjs` 生成:{ serverUrl, runnerToken, adminToken, appId }
16
+ * ⛔ 不打印 token;⛔ 不写生产(state 文件只由本地 dev-local 生成)。
17
+ */
18
+ import { readFileSync } from 'node:fs';
19
+
20
+ const argv = process.argv.slice(2);
21
+ const val = (flag, dflt = '') => {
22
+ const i = argv.indexOf(flag);
23
+ return i > -1 ? (argv[i + 1] ?? dflt) : dflt;
24
+ };
25
+ const has = (flag) => argv.includes(flag);
26
+
27
+ const statePath = val('--state', '/tmp/argent-dev-local.json');
28
+ let state;
29
+ try { state = JSON.parse(readFileSync(statePath, 'utf8')); } catch (e) {
30
+ console.error(`❌ 读不到状态文件 ${statePath}:${e.message}\n 先在 whyshu-svr 起本地服务:node scripts/dev-local.mjs`);
31
+ process.exit(2);
32
+ }
33
+ const { serverUrl, runnerToken, appId } = state;
34
+ if (!serverUrl || !runnerToken || !appId) { console.error('❌ 状态文件缺 serverUrl/runnerToken/appId'); process.exit(2); }
35
+
36
+ const api = async (path, { method = 'GET', body } = {}) => {
37
+ const res = await fetch(`${serverUrl}/api/argent/v1${path}`, {
38
+ method,
39
+ headers: {
40
+ authorization: `Bearer ${runnerToken}`,
41
+ 'content-type': 'application/json',
42
+ ...(has('--idem-key') ? { 'idempotency-key': val('--idem-key') } : {}),
43
+ },
44
+ body: body === undefined ? undefined : JSON.stringify(body),
45
+ });
46
+ const text = await res.text();
47
+ let data = null;
48
+ try { data = JSON.parse(text); } catch { data = text; }
49
+ if (!res.ok) {
50
+ throw new Error(`${method} ${path} → ${res.status} ${data?.error?.code || ''} ${data?.error?.message || ''}`.trim());
51
+ }
52
+ return data;
53
+ };
54
+
55
+ const main = async () => {
56
+ const tenantPath = `/tenants/${appId}`;
57
+
58
+ if (has('--create-card')) {
59
+ const title = val('--create-card');
60
+ const preset = val('--preset');
61
+ const d = await api(`${tenantPath}/cards`, { method: 'POST', body: { title, description: val('--description'), assigneePreset: preset } });
62
+ console.log(JSON.stringify({ ok: true, cardId: d.data.card._id, title, appId }));
63
+ return;
64
+ }
65
+
66
+ if (has('--report')) {
67
+ const cardId = val('--card-id');
68
+ const preset = val('--preset', 'argent-ml-daily');
69
+ const outcome = val('--outcome', 'succeeded');
70
+ const tokens = Number(val('--tokens', '0')) || 0;
71
+ const summary = val('--summary', '');
72
+ const run = await api(`${tenantPath}/runs`, { method: 'POST', body: { cardId: cardId || null, preset } });
73
+ const runId = run.data.run._id;
74
+ const settled = await api(`${tenantPath}/runs/${runId}/settle`, {
75
+ method: 'PATCH',
76
+ body: { outcome, tokens, cost: Number(val('--cost', '0')) || 0, summary },
77
+ });
78
+ console.log(JSON.stringify({
79
+ ok: true,
80
+ runId,
81
+ outcome: settled.data.run.outcome,
82
+ cardStatus: settled.data.card?.status ?? null,
83
+ cardSkipped: settled.data.cardSkipped ?? false,
84
+ }));
85
+ return;
86
+ }
87
+
88
+ if (has('--verify')) {
89
+ const cardId = val('--card-id');
90
+ const cards = await api(`${tenantPath}/cards?limit=50`);
91
+ const runs = await api(`${tenantPath}/runs?limit=50`);
92
+ const card = cardId ? cards.data.items.find((c) => String(c._id) === String(cardId)) : null;
93
+ const relatedRuns = cardId ? runs.data.items.filter((r) => String(r.cardId) === String(cardId)) : [];
94
+ console.log(JSON.stringify({ ok: true, card, runs: relatedRuns }, null, 2));
95
+ return;
96
+ }
97
+
98
+ console.error('用法:--create-card <title> | --report ... | --verify ...(详见脚本头注释)');
99
+ process.exit(2);
100
+ };
101
+
102
+ main().catch((e) => { console.error(`❌ ${e.message}`); process.exit(1); });
@@ -1,15 +1,40 @@
1
+ # 加固(B 类 · 防毒化):Session.append 边界补 message 形状校验 —— **dsh 0.2.1-rc.2 移植版**
2
+ #
3
+ # 背景(2026-09-18 会话毒化事故):append() 原本只做 validateSessionEventData + surface 校验,
4
+ # **漏了** assertMessageEventShape ⇒ 一个非法 message 事件(如某个 tool 的 render 返回裸字符串)
5
+ # 能写进日志,直到**下次从磁盘加载**才炸("message must contain one tool-result block"),
6
+ # 此时会话根本打不开,surface replace 也救不回来(校验针对事件本身,不是 surface 视图)。
7
+ #
8
+ # 版本沿革 / 为什么这次要改:
9
+ # · 2026-09-18 首版:面向 0.1.7/0.2.0 —— 插入点是 `validateSessionEventData(...)` 与
10
+ # `this.surfaceManager.validateNext(event)` 之间(当时 append() 内联了这个调用)。
11
+ # · 2026-10-18 移植到 **0.2.1-rc.2**:上游把 `surfaceManager.validateNext(event)` 移进了
12
+ # `commit()`,append() 变成 `validateSessionEventData(...) → this.commit(event, entry)`;
13
+ # 旧 hunk 的上下文消失 ⇒ 补丁应用失败、加固门禁变红(实测)。故插入点改为
14
+ # **`validateSessionEventData(...)` 之后、`this.commit(event, entry)` 之前**。
15
+ # · ⚠️ 上游 0.2.1 **并未**原生补上该校验:`assertMessageEventShape` 只在 `adoptSessionEvent()`
16
+ # 里调用(那是另一条"接收获批事件"的路径),append/commit 仍未调用 ⇒ 加固仍然必要。
17
+ # · 0.1.7/0.2.0 树的旧版补丁见 git 历史(本文件在提交 987de39 之前的版本);回滚到旧树时
18
+ # 物理备份树里**已含**该补丁的修改,无需重打;`--patches-only` 在旧树上会对此文件报 warn,
19
+ # 属预期(按 `dsh-upgrade.mjs --rollback` 流程走,别拿"先别重启"卡自己)。
20
+ #
21
+ # 退役条件:上游把 assertMessageEventShape 接进 append/commit 路径后,patch --forward 会因上下文不符
22
+ # 而 warn ⇒ 届时删除本文件并同步删掉 scripts/patches/verify-hardening.mjs 的补丁 B 部分。
1
23
  --- /opt/homebrew/lib/node_modules/@deepseek-ai/dsh-session/lib/index.js.orig
2
24
  +++ /opt/homebrew/lib/node_modules/@deepseek-ai/dsh-session/lib/index.js
3
- @@ -1187,6 +1187,12 @@
25
+ @@ -1533,6 +1533,15 @@
4
26
  ...surfaceMetadataSnapshot
5
27
  });
6
28
  validateSessionEventData(event, `session event "${type}" at seq ${event.seq}`);
7
- + /* Hardening (2026-09-18, argent-dsh hotfix): the append boundary must reject a
8
- + message event that could never be replayed. Otherwise a malformed tool/result
9
- + (e.g. a render that returned a bare string) lands in the log and only fails
10
- + when the session is next LOADED from disk — by then the session cannot even be
11
- + opened, and a surface replace cannot repair it. Fail at the writer instead. */
29
+ + /* Hardening (2026-09-18, argent-dsh hotfix;2026-10-18 移植到 dsh 0.2.1):
30
+ + the append boundary must reject a message event that could never be replayed.
31
+ + Otherwise a malformed tool/result (e.g. a render that returned a bare string)
32
+ + lands in the log and only fails when the session is next LOADED from disk — by
33
+ + then the session cannot even be opened, and a surface replace cannot repair it.
34
+ + Fail at the writer instead.
35
+ + 0.2.1 结构变化:append() 不再直接调 surfaceManager.validateNext(移进 commit()),
36
+ + 故插入点改为 validateSessionEventData 之后、commit 之前;校验函数/语义未变。 */
12
37
  + assertMessageEventShape(event, `session event "${type}" at seq ${event.seq}`);
13
- this.surfaceManager.validateNext(event);
14
- if (entry !== void 0) entry.appending = true;
15
- try {
38
+ this.commit(event, entry);
39
+ return event;
40
+ }
@@ -1,20 +1,44 @@
1
- # requires-dsh: >=0.1.7 (v3→v4 迁移器 0.1.7 新增;修复"turn 外替换型 tool/result"过严拒绝)
1
+ # 放宽 v4 加载校验:允许"turn 结束后的**替换型** tool/result"(合法历史,不是损坏)
2
+ #
3
+ # 背景(2026-10-17,本机 0.1.7-rc.2 → 0.2.0-rc.2 重启后):
4
+ # 用户主会话 06732069(v4、95032 事件)在 0.2.0 上打不开:
5
+ # Failed to load history: stored session "…06732069…" is corrupt:
6
+ # SessionFormatError: tool/result is outside an open turn
7
+ # 真因:v4 校验器(本包 `tool()`)对 `surfaceOp !== "append"` 的 tool/result 一律 `requireTurn`;
8
+ # 而 v3 时代就允许"turn 结束后重写早先 turn 的工具结果"(编辑/重放/surface 自替换)。
9
+ # 本机实测只有 **1 处**:seq 30732,surfaceOp.replace(startSeq=30702,endSeq=30702),
10
+ # data.turn=541 而当时 nextTurn=545 ⇒ 指向**已关闭的历史 turn**,属合法历史。
11
+ #
12
+ # 口径:turn 外时只要求 data.turn 是**已关闭的历史 turn**(安全整数且 < nextTurn);
13
+ # 其余情况仍抛原错 ⇒ 防真损坏,放行合法历史。
14
+ #
15
+ # 来源:等价于已退役 A 类补丁 `dsh-session-format-v3-to-v4--turn-outside-replace.patch`
16
+ # (2026-09-29 为 0.1.7 修同一坑;A 类于 2026-10-16 退役)。0.2.0 是**新旁路树**,
17
+ # 退役补丁不会随树迁移 ⇒ 该缺陷在新树上复现;本文件是 0.2.0 树的移植版,`--patches-only` 会自动重打。
18
+ #
19
+ # 为什么不写 `# requires-dsh:` 版本门:① 本补丁的上下文在 0.1.7 与 0.2.0 上一致,两版都适用;
20
+ # ② `applyHotfixPatches()` 里的版本门目前**静默失效**(正则被行尾注释打断)且依赖未定义的
21
+ # `cmpVerText` —— 写一行能被识别的门反而会 ReferenceError 崩掉升级脚本(待单独修)。
22
+ # 退役条件:上游把这条规则放宽(或提供配置)后,本补丁 `patch --forward` 会因上下文不符而 warn ⇒ 再删。
2
23
  --- /opt/homebrew/lib/node_modules/@deepseek-ai/dsh-session-format-v3-to-v4/lib/index.js.orig
3
24
  +++ /opt/homebrew/lib/node_modules/@deepseek-ai/dsh-session-format-v3-to-v4/lib/index.js
4
- @@ -617,7 +617,15 @@
25
+ @@ -617,6 +617,19 @@
5
26
  }
6
27
  tool(event, data) {
7
28
  if (event.type === "tool/result" && event["surfaceOp"] !== "append") {
8
- - this.requireTurn(event.type);
9
- + /* Hardening (2026-09-29, argent-dsh hotfix for 0.1.7): v3 允许"turn 结束后的替换型
29
+ + /* Argent hotfix(0.2.0-rc.2 移植版,2026-10-17):v3/v4 允许"turn 结束后的替换型
10
30
  + tool/result"(编辑/重放场景:重写早先 turn 的工具结果,如 surface 自替换)。
11
- + 原 requireTurn 过严 → 整个 session 迁移失败('tool/result is outside an
12
- + open turn',实测 06732069/34c3e088 等)。现在:turn 外时校验 data.turn 指向
13
- + 已关闭的历史 turn(防真损坏,放行合法历史)。 */
31
+ + 原 `requireTurn` 过严 ⇒ 整个 session **加载**失败('tool/result is outside an
32
+ + open turn';本机 06732069 实测 1 处:seq 30732,surfaceOp.replace 指向 seq 30702,
33
+ + data.turn=541 而当时 nextTurn=545)。放宽口径:turn 外时 data.turn 必须是
34
+ + **已关闭的历史 turn**(安全整数且 < nextTurn)—— 防真损坏,放行合法历史。
35
+ + 等价于已退役 A 类补丁 dsh-session-format-v3-to-v4--turn-outside-replace
36
+ + (0.1.7 上曾修同一坑);本次因 0.2.0 树是**新树**(补丁未随树迁移)而复现。 */
14
37
  + if (this.turn === null) {
15
38
  + const t = data["turn"];
16
39
  + if (!Number.isSafeInteger(t) || t >= this.nextTurn) throw new SessionFormatError(`${event.type} is outside an open turn`);
40
+ + return;
17
41
  + }
42
+ this.requireTurn(event.type);
18
43
  return;
19
44
  }
20
- this.requireStep(event, data);
@@ -10,10 +10,6 @@ declare -A PKG=(
10
10
  [dsh-session--append-message-shape]="@deepseek-ai/dsh-session"
11
11
  [dsh-session--tool-result-block-017]="@deepseek-ai/dsh-session"
12
12
  [dsh-tools--render-return-shape]="@deepseek-ai/dsh-tools"
13
- [dsh-session-format-v0-to-v1--permission-preset-origin]="@deepseek-ai/dsh-session-format-v0-to-v1"
14
- [dsh-session-format-v3-to-v4--turn-outside-replace]="@deepseek-ai/dsh-session-format-v3-to-v4"
15
- [dsh-session-persistence-jsonl--worker-bundled-migrators]="@deepseek-ai/dsh-session-persistence-jsonl"
16
- [dsh-llm-deepseek--transport-diagnostics]="@deepseek-ai/dsh-llm-deepseek"
17
13
  )
18
14
  echo "上游版本 $VER | 临时目录 $TMP"
19
15
  cd "$TMP"
@@ -80,7 +80,10 @@ console.log('\n【补丁 B】dsh-session:append 边界补上 message 形状校
80
80
  if (!existsSync(SESSION)) { ok(false, `dsh-session 存在(${SESSION})`) }
81
81
  else {
82
82
  const src = readFileSync(SESSION, 'utf8')
83
- ok(/Hardening \(2026-09-18, argent-dsh hotfix\)/.test(src), '打补丁标记存在')
83
+ // 2026-10-18:标记判据放宽为"前缀匹配"——补丁按宿主版本移植时注释尾部会变
84
+ // (0.2.1 版 = `Hardening (2026-09-18, argent-dsh hotfix;2026-10-18 移植到 dsh 0.2.1)`),
85
+ // 但"这是本仓库的补丁、不是上游原生行为"这件事不变。⛔ 不要退回成要求精确闭合括号。
86
+ ok(/Hardening \(2026-09-18, argent-dsh hotfix/.test(src), '打补丁标记存在')
84
87
  const appendBody = src.slice(src.indexOf('\tappend(type, data, ...opts) {'), src.indexOf('headerFold;', src.indexOf('\tappend(type, data, ...opts) {')))
85
88
  ok(/validateSessionEventData\(event, `session event "\$\{type\}" at seq \$\{event\.seq\}`\);\s*\n\s*\/\* Hardening[\s\S]*?\*\/\s*\n\s*assertMessageEventShape\(event, `session event "\$\{type\}" at seq \$\{event\.seq\}`\);/.test(appendBody),
86
89
  'append() 在落盘前调用 assertMessageEventShape()')