Bläddra i källkod

refactor: 重构为源库+打包架构 —— dist 由脚本从源仓库拉真实文件生成

问题:原库子技能只有手写简版 SKILL.md(34-52行),缺完整流程与触发词,
Agent 装完检索不到触发条件。

改造:
- skill-sources.json: 技能源映射表(唯一维护入口,11个技能已登记)
- scripts/build-dist.mjs: 打包脚本(拉源/clone/copy/规范化/品牌替换)
- scripts/normalize-skill.mjs: frontmatter 规范化(自动补触发词)
- dist/ = 上传 SkillHub 的产物,内容 100% 来自源仓库
- 归档旧手写占位到 .legacy/

验收:11 技能 / 触发词覆盖 11/11 / 品牌违规 0 / .git泄漏 0
liuyuyang 11 timmar sedan
förälder
incheckning
31fd7d85b3
100 ändrade filer med 11784 tillägg och 775 borttagningar
  1. 12 0
      .gitignore
  2. 0 105
      SKILL.md
  3. 51 0
      dist/skills-index.json
  4. 52 0
      dist/skills/fmode-agent-log/SKILL.md
  5. BIN
      dist/skills/fmode-agent-log/scripts/__pycache__/collect_metrics.cpython-313.pyc
  6. BIN
      dist/skills/fmode-agent-log/scripts/__pycache__/upload_log.cpython-313.pyc
  7. 108 0
      dist/skills/fmode-agent-log/scripts/collect_metrics.py
  8. 9 0
      dist/skills/fmode-agent-log/scripts/report.sh
  9. 49 0
      dist/skills/fmode-agent-log/scripts/upload_log.py
  10. 375 0
      dist/skills/fmode-deliverable/LICENSE
  11. 227 0
      dist/skills/fmode-deliverable/README.md
  12. 119 0
      dist/skills/fmode-deliverable/SKILL.md
  13. 82 0
      dist/skills/fmode-deliverable/bin/skill-deliverable.mjs
  14. 115 0
      dist/skills/fmode-deliverable/cloud/deliverable-report-v2.js
  15. 145 0
      dist/skills/fmode-deliverable/cloud/deliverable-report-v2.platform.js
  16. 11 0
      dist/skills/fmode-deliverable/docs/CHANGELOG.md
  17. 176 0
      dist/skills/fmode-deliverable/docs/deployment-guide.md
  18. 126 0
      dist/skills/fmode-deliverable/lib/index.mjs
  19. 59 0
      dist/skills/fmode-deliverable/package.json
  20. 71 0
      dist/skills/fmode-deliverable/skills/skill-deliverable/SKILL.md
  21. 48 0
      dist/skills/fmode-heterarchy/CHANGELOG.md
  22. 18 0
      dist/skills/fmode-heterarchy/LESSONS.md
  23. 375 0
      dist/skills/fmode-heterarchy/LICENSE
  24. 227 0
      dist/skills/fmode-heterarchy/README.md
  25. 464 0
      dist/skills/fmode-heterarchy/SKILL.md
  26. 34 0
      dist/skills/fmode-heterarchy/TASK-FIX-V21.md
  27. 113 0
      dist/skills/fmode-heterarchy/TASK-REFACTOR-V2.md
  28. 49 0
      dist/skills/fmode-heterarchy/bin/install.js
  29. 193 0
      dist/skills/fmode-heterarchy/lib/index.mjs
  30. 62 0
      dist/skills/fmode-heterarchy/package.json
  31. 98 0
      dist/skills/fmode-heterarchy/scripts/selftest.mjs
  32. 12 0
      dist/skills/fmode-heterarchy/skill-package-manifest.json
  33. 102 0
      dist/skills/fmode-image/SKILL.md
  34. 130 0
      dist/skills/fmode-listen/SKILL.md
  35. 388 0
      dist/skills/fmode-listen/scripts/listen-runner.mjs
  36. 129 0
      dist/skills/fmode-product-lab/SKILL.md
  37. 375 0
      dist/skills/fmode-report-publish/LICENSE
  38. 194 0
      dist/skills/fmode-report-publish/README.md
  39. 190 0
      dist/skills/fmode-report-publish/SKILL.md
  40. 53 0
      dist/skills/fmode-report-publish/package.json
  41. 206 0
      dist/skills/fmode-storage/SKILL.md
  42. 160 0
      dist/skills/fmode-storage/docs/skill-storage-intro.html
  43. 523 0
      dist/skills/fmode-storage/scripts/gateway.mjs
  44. 752 0
      dist/skills/fmode-storage/scripts/uploader.mjs
  45. 158 0
      dist/skills/fmode-storage/templates/gateway.html
  46. 37 0
      dist/skills/fmode-video-editing/README.md
  47. 60 0
      dist/skills/fmode-video-editing/SKILL.md
  48. 79 0
      dist/skills/fmode-video-lapian/README.md
  49. 145 0
      dist/skills/fmode-video-lapian/SKILL.md
  50. 117 0
      dist/skills/fmode-video-lapian/js/aigc_report.mjs
  51. 245 0
      dist/skills/fmode-video-lapian/js/aigc_storyboard.mjs
  52. 135 0
      dist/skills/fmode-video-lapian/js/type_classify.mjs
  53. 74 0
      dist/skills/fmode-video-lapian/references/methodology.md
  54. 73 0
      dist/skills/fmode-video-lapian/references/s3-upload.md
  55. 64 0
      dist/skills/fmode-video-lapian/references/voc-api.md
  56. BIN
      dist/skills/fmode-video-lapian/scripts/__pycache__/common.cpython-313.pyc
  57. 491 0
      dist/skills/fmode-video-lapian/scripts/build_report.py
  58. 427 0
      dist/skills/fmode-video-lapian/scripts/common.py
  59. 63 0
      dist/skills/fmode-video-lapian/scripts/fetch_comments.py
  60. 156 0
      dist/skills/fmode-video-lapian/scripts/fetch_video.py
  61. 146 0
      dist/skills/fmode-video-lapian/scripts/frame_analysis.py
  62. 102 0
      dist/skills/fmode-video-lapian/scripts/publish.py
  63. 114 0
      dist/skills/fmode-video-lapian/scripts/publish_via_obs.py
  64. 72 0
      dist/skills/fmode-video-lapian/scripts/transcribe.py
  65. 241 0
      dist/skills/fmode-video-lapian/templates/report.html.j2
  66. 204 0
      dist/skills/fmode-vision/README.md
  67. 210 0
      dist/skills/fmode-vision/SKILL.md
  68. 419 0
      dist/skills/fmode-vision/scripts/prompts/room-measurement.mjs
  69. 617 0
      dist/skills/fmode-vision/scripts/vision-client.mjs
  70. 101 0
      docs/REPOS-INDEX.md
  71. 18 7
      package.json
  72. 235 0
      scripts/build-dist.mjs
  73. 197 0
      scripts/normalize-skill.mjs
  74. 102 0
      skill-sources.json
  75. 0 51
      skills/fmode-account/SKILL.md
  76. 0 5
      skills/fmode-account/references/endpoints.md
  77. 0 30
      skills/fmode-account/scripts/index.mjs
  78. 0 49
      skills/fmode-cloud-functions/SKILL.md
  79. 0 3
      skills/fmode-cloud-functions/references/endpoints.md
  80. 0 28
      skills/fmode-cloud-functions/scripts/index.mjs
  81. 0 49
      skills/fmode-cloud-resources/SKILL.md
  82. 0 3
      skills/fmode-cloud-resources/references/endpoints.md
  83. 0 28
      skills/fmode-cloud-resources/scripts/index.mjs
  84. 0 36
      skills/fmode-courseware/SKILL.md
  85. 0 3
      skills/fmode-courseware/references/source.md
  86. 0 51
      skills/fmode-enterprise/SKILL.md
  87. 0 5
      skills/fmode-enterprise/references/endpoints.md
  88. 0 30
      skills/fmode-enterprise/scripts/index.mjs
  89. 0 52
      skills/fmode-git-archive/SKILL.md
  90. 0 5
      skills/fmode-git-archive/references/endpoints.md
  91. 0 30
      skills/fmode-git-archive/scripts/index.mjs
  92. 0 34
      skills/fmode-listen/SKILL.md
  93. 0 3
      skills/fmode-listen/references/source.md
  94. 0 36
      skills/fmode-pay/SKILL.md
  95. 0 3
      skills/fmode-pay/references/source.md
  96. 0 36
      skills/fmode-product-lab/SKILL.md
  97. 0 3
      skills/fmode-product-lab/references/source.md
  98. 0 36
      skills/fmode-report-publish/SKILL.md
  99. 0 3
      skills/fmode-report-publish/references/source.md
  100. 0 51
      skills/fmode-static-publish/SKILL.md

+ 12 - 0
.gitignore

@@ -0,0 +1,12 @@
+# 打包缓存(各技能源仓库的本地 clone)
+.cache/
+
+# 旧版遗留(已归档,不入库)
+.legacy/
+
+# 依赖
+node_modules/
+
+# 系统文件
+.DS_Store
+*.log

+ 0 - 105
SKILL.md

@@ -1,105 +0,0 @@
----
-slug: fmode-skill-fmode-skills
-displayName: fmode-skills
-version: 0.2.0
-name: fmode-skills
-description: |
-  Fmode 总技能库(Meta 技能)。一次安装,所有 Fmode 子技能自动落盘:
-  数字分身初始化(手机注册/登录/企业开通)、云资源领取(华为云 STS 部署)、
-  前后端项目发布(函数云函数 + 静态 OBS+CDN)、Git 归档、VOC 情报
-  (小红书/抖音/视频号)、企微办公、录音转写、视觉分析、媒体处理、微信支付进件等。
-  用户只需安装本技能,运行 installer 后全部子技能按宿主自动分发到
-  ~/.workbuddy/skills/ 或 ~/.claude/skills/。
-tags: [FmodeAgent, HermesAgent, FmodeCode, 系统级, 技能库, 安装器, meta]
-platform: Both
-level: 系统级
-category: 平台基础设施
-icon: 📦
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# 📦 Fmode 总技能库(fmode-skills)
-
-> 「让AI进化提前发生,让AI落地快人一步」
-> 未来飞马 — 一次安装,全部 Fmode 能力自动落盘。
-
-## 这是什么
-
-**Fmode 总技能库**是一个**元技能(Meta Skill)**:本身不实现业务,而是承载
-`installer.mjs` —— 检测宿主 Agent 后,把仓库内全部子技能**自动分发到对应技能目录**。
-
-用户只需要安装一个技能(本技能),其他所有技能自动完成安装。
-
-## 快速开始
-
-### 方式 1:仓库安装(Gogs / GitHub)
-
-```bash
-git clone https://git.fmode.cn/fmode/fmode-skills.git
-cd fmode-skills
-node scripts/installer.mjs --all        # 分发全部子技能到当前宿主
-node scripts/installer.mjs --check      # 检查现状(不写文件)
-```
-
-### 方式 2:skillhub 安装(检索/一键)
-
-在 skillhub.cn 检索 `fmode-skills`,按平台指引安装后运行仓库内 installer。
-
-## 安装器行为
-
-| 宿主 | 落盘目录 | 说明 |
-|------|---------|------|
-| WorkBuddy | `~/.workbuddy/skills/` | 纯 SKILL.md 兼容 |
-| Claude Code / FmodeCode | `~/.claude/skills/` | ESM 技能目录 |
-| Hermes Agent | `~/.hermes/skills/` | SKILL.md + scripts |
-
-每个子技能目录结构(ESM 标准):
-
-```text
-fmode-<skill>/
-├── SKILL.md            # 触发词 + 完整流程
-├── scripts/            # 可执行助手(fetch 封装,零 npm 依赖)
-└── references/         # 可选:参考数据
-```
-
-## 包含的子技能(v0.2,纯接口驱动)
-
-> **架构铁律**:所有子技能只依赖「公开 HTTP 接口 + 用户 token(从环境变量读取)+ Agent 原生工具」,
-> **不依赖任何需另行安装的桌面软件、本地二进制或 fmode-studio**。
-> 用户只需要在 Agent 工具里安装本技能库,AI 即可调用已封装的公开接口完成任务。
-
-| # | 子技能 | 层级 | 能力 |
-|---|--------|------|------|
-| 1 | `fmode-account` | 服务级 | 手机验证码注册/登录/sessionToken |
-| 2 | `fmode-enterprise` | 服务级 | 企业开通状态/Company 查询 |
-| 3 | `fmode-cloud-functions` | 服务级 | 云函数部署与调用(/api/functions)|
-| 4 | `fmode-cloud-resources` | 服务级 | 云资源领取(华为云 STS)|
-| 5 | `fmode-static-publish` | 服务级 | 静态站/课件发布(OBS+CDN)|
-| 6 | `fmode-git-archive` | 服务级 | 项目 Git 归档(Gitea)|
-| 7 | `fmode-vision` | 服务级 | 视觉识别(公开 API)|
-| 8 | `fmode-listen` | 服务级 | 录音转写(公开网关 API)|
-| 9 | `fmode-storage` | 服务级 | 对象存储与公开分享 |
-| 10 | `fmode-voc-intelligence` | 应用级 | 小红书/抖音/视频号 VOC 情报 |
-| 11 | `fmode-tihao` | 应用级 | 提号 SOP 达人研究 |
-| 12 | `fmode-product-lab` | 应用级 | 新品研发 |
-| 13 | `fmode-pay` | 应用级 | 微信特约商户进件 |
-| 14 | `fmode-courseware` | 应用级 | 课程课件生产 |
-| 15 | `fmode-report-publish` | 应用级 | HTML 报告发布 |
-
-> **已剔除**(依赖本地/软件,违反纯接口铁律):`fmode-ffmpeg`(需本地 ffmpeg 二进制)、`fmode-qiwei`(需企微网关部署)。如需这些能力,由用户自行安装单独包。
-> 系统层(s 级运维技能)剥离在内部仓库,不进公开 skillhub。
-
-## 分发到其他库
-
-- Gogs:`git.fmode.cn/fmode/fmode-skills`
-- skillhub:`fmode-skill-fmode-skills`
-- GitHub:`github.com/fmodecn/fmode-skills`(可选镜像)
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)
-
-> MPL-2.0 governs copyright for source code only. This license does NOT grant
-> any right to use our trademarks: 未来飞马, Harness Loop, RSI, and the slogan
-> "让AI进化提前发生,让AI落地快人一步".

+ 51 - 0
dist/skills-index.json

@@ -0,0 +1,51 @@
+{
+  "name": "fmode-skills",
+  "version": "1.1.0",
+  "builtAt": "2026-10-30T15:44:18.203Z",
+  "skills": [
+    {
+      "name": "fmode-storage",
+      "path": "skills/fmode-storage"
+    },
+    {
+      "name": "fmode-vision",
+      "path": "skills/fmode-vision"
+    },
+    {
+      "name": "fmode-listen",
+      "path": "skills/fmode-listen"
+    },
+    {
+      "name": "fmode-product-lab",
+      "path": "skills/fmode-product-lab"
+    },
+    {
+      "name": "fmode-image",
+      "path": "skills/fmode-image"
+    },
+    {
+      "name": "fmode-report-publish",
+      "path": "skills/fmode-report-publish"
+    },
+    {
+      "name": "fmode-heterarchy",
+      "path": "skills/fmode-heterarchy"
+    },
+    {
+      "name": "fmode-deliverable",
+      "path": "skills/fmode-deliverable"
+    },
+    {
+      "name": "fmode-agent-log",
+      "path": "skills/fmode-agent-log"
+    },
+    {
+      "name": "fmode-video-lapian",
+      "path": "skills/fmode-video-lapian"
+    },
+    {
+      "name": "fmode-video-editing",
+      "path": "skills/fmode-video-editing"
+    }
+  ]
+}

+ 52 - 0
dist/skills/fmode-agent-log/SKILL.md

@@ -0,0 +1,52 @@
+---
+name: "skill-agent-log-s3"
+description: "触发词:日志上报、性能记录、运行状态。数字生命自报运行日志到个人S3空间log/目录。"
+version: "1.0.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [日志上报, 性能记录, 运行状态]
+---
+
+# 数字生命自报日志 (skill-agent-log-s3)
+
+> 命名规则: skill-agent-log-s3 = agent领域的log功能,存到S3
+> 核心逻辑: 每个生命**用自己的密钥**把运行状态写进**自己的**S3空间(log/目录),
+> 主协调者(雨飏001)每天只需下载各家日志做分析,不消耗监控资源。
+
+## 我上报什么
+
+| 维度 | 指标 |
+|------|------|
+| 并发 | 活跃 profile 数、活跃 session 数、运行中 sub-agent 数 |
+| 资源 | CPU%、内存 used/total、磁盘 used/total、GPU(有则记) |
+| 网络 | 累计上行/下行字节 |
+| 进程 | gateway/dashboard/studio 存活状态 |
+| 事件 | 自上次上报以来的异常/重启/错误计数 |
+
+## 怎么上报
+
+```bash
+bash ~/.fmode-harness-agent/skills/agent-log-s3/report.sh
+```
+- 脚本采集本机指标 → JSON
+- 写入 `~/.fmode-harness-agent/tmp/agent-log.json`
+- 用**自己的**身份(FEME_USERID + S3凭证)上传到:
+  `user/<我的userid>/log/<YYYYMMDD>/<HHMM>.json`
+- 保留最近N份,自然滚动
+
+## 上报频率建议
+
+- 常态: 每 6 小时一次(cron)
+- 重任务执行时: 任务开始/结束各加一条
+- 手动: 随时跑 report.sh
+
+## 主协调者的收集
+
+雨飏001每天跑 collect-all.sh:
+- 逐个生命的 S3 前缀 `user/<userid>/log/` 列举最新文件
+- 下载汇总 → 分析 → 生成日报(容量/异常/趋势)
+
+## 依赖
+
+- boto3 (S3协议)
+- 本身份 fmode-identity.json (userid) + S3凭证(env或credentials API)

BIN
dist/skills/fmode-agent-log/scripts/__pycache__/collect_metrics.cpython-313.pyc


BIN
dist/skills/fmode-agent-log/scripts/__pycache__/upload_log.cpython-313.pyc


+ 108 - 0
dist/skills/fmode-agent-log/scripts/collect_metrics.py

@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+"""采集本机运行指标 → JSON (跨平台: Linux/macOS/Windows-WSL)"""
+# 数据根目录可通过 FMODE_DATA_DIR 覆盖(默认 ~/.fmode 或 /opt/data)
+import json, os, subprocess, datetime, socket, sys, time
+
+DATA_DIR = os.environ.get("FMODE_DATA_DIR") or (
+    "/opt/data" if os.path.isdir("/opt/data/profiles") else os.path.expanduser("~/.hermes")
+)
+WORK = os.path.join(DATA_DIR, ".fmode-harness-agent")
+
+def sh(cmd, timeout=15):
+    try:
+        return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout).stdout.strip()
+    except Exception as e:
+        return f"ERR:{e}"
+
+def collect():
+    now = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00")
+    data = {
+        "timestamp": now,
+        "agent": os.environ.get("AGENT_NAME", socket.gethostname()),
+        "host": {
+            "platform": sys.platform,
+            "hostname": socket.gethostname(),
+        },
+        "resources": {},
+        "concurrency": {},
+        "services": {},
+        "events": {}
+    }
+
+    # --- CPU/内存 (跨平台: /proc/meminfo + uptime) ---
+    try:
+        meminfo = {}
+        for line in open("/proc/meminfo"):
+            k, v = line.split(":")
+            meminfo[k.strip()] = int(v.strip().split()[0])  # kB
+        total = meminfo["MemTotal"]
+        avail = meminfo["MemAvailable"]
+        data["resources"]["memory"] = {
+            "total_mb": round(total / 1024),
+            "used_mb": round((total - avail) / 1024),
+            "percent": round((total - avail) / total * 100, 1)
+        }
+        load = open("/proc/loadavg").read().split()
+        data["resources"]["cpu_load"] = {"m1": float(load[0]), "m5": float(load[1]), "m15": float(load[2])}
+        # CPU%
+        cpu = sh("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'")
+        data["resources"]["cpu_percent"] = round(float(cpu), 1) if cpu and not cpu.startswith("ERR") else None
+    except Exception as e:
+        data["resources"]["error"] = str(e)[:100]
+
+    # --- 磁盘 (工作区所在分区) ---
+    disk = sh(f"df -B1 {WORK} | tail -1 | awk '{{print $2, $3, $5}}'")
+    if disk and not disk.startswith("ERR"):
+        t, u, p = disk.split()
+        data["resources"]["disk"] = {"total_gb": round(int(t)/1e9,1), "used_gb": round(int(u)/1e9,1), "percent": p}
+
+    # --- 网络 (累计上下行, 取主网卡) ---
+    net = sh("cat /proc/net/dev | awk 'NR>2 && $1!~/lo:/ {gsub(\":\",\"\",$1); print $1, $2, $10}' | sort -k2 -rn | head -1")
+    if net and not net.startswith("ERR"):
+        iface, rx, tx = net.split()
+        data["resources"]["network"] = {"iface": iface, "rx_total_mb": round(int(rx)/1e6,1), "tx_total_mb": round(int(tx)/1e6,1)}
+
+    # --- GPU (有则记) ---
+    gpu = sh("nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits 2>/dev/null")
+    if gpu:
+        u, mu, mt = gpu.split(", ")
+        data["resources"]["gpu"] = {"percent": int(u), "mem_used_mb": int(mu), "mem_total_mb": int(mt)}
+
+    # --- 并发: profile / session / subagent ---
+    profiles = sh("ls %s/profiles/ 2>/dev/null | wc -l" % DATA_DIR)
+    data["concurrency"]["profiles_total"] = int(profiles) if profiles.isdigit() else None
+    # 活跃session(hermes state.db最近30分钟)
+    try:
+        import sqlite3
+        conn = sqlite3.connect("file:%s/state.db?mode=ro" % DATA_DIR, uri=True, timeout=5)
+        cur = conn.cursor()
+        cur.execute("SELECT COUNT(DISTINCT session_id) FROM messages WHERE timestamp > ?", (time.time()-1800,))
+        data["concurrency"]["active_sessions_30min"] = cur.fetchone()[0]
+        cur.execute("SELECT COUNT(*) FROM async_delegations WHERE status='running'")
+        data["concurrency"]["running_subagents"] = cur.fetchone()[0]
+        conn.close()
+    except Exception:
+        data["concurrency"]["active_sessions_30min"] = None
+
+    # --- 服务存活 ---
+    for svc, pat in [("gateway", "hermes gateway"), ("dashboard", "hermes-dashboard"), ("studio", "fmode-studio")]:
+        out = sh(f"pgrep -f '{pat}' | wc -l")
+        data["services"][svc] = "up" if out.isdigit() and int(out) > 0 else "down"
+
+    # --- 事件: 最近1小时错误日志计数 ---
+    errc = sh("find %s/logs -name '*.log' -mmin -60 -exec grep -ci 'error\\|fatal' {} + 2>/dev/null | awk -F: '{s+=$2} END {print s}'")
+    data["events"]["errors_last_1h"] = int(errc) if errc.isdigit() else None
+    rest = sh("grep -c 'Reconnected' %s/logs/gateway.log 2>/dev/null")
+    data["events"]["wecom_reconnects_total"] = int(rest) if rest.isdigit() else None
+
+    return data
+
+if __name__ == "__main__":
+    import time
+    out = os.path.join(WORK, "tmp")
+    os.makedirs(out, exist_ok=True)
+    d = collect()
+    path = os.path.join(out, "agent-log.json")
+    json.dump(d, open(path, "w"), ensure_ascii=False, indent=2)
+    print(json.dumps(d, ensure_ascii=False)[:300])
+    print(f"\nsaved: {path}")

+ 9 - 0
dist/skills/fmode-agent-log/scripts/report.sh

@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# 一键上报: 采集 + 上传 (cron入口)
+set -euo pipefail
+WORK="$HOME/.fmode-harness-agent"
+SKILL="$WORK/skills/agent-log-s3/scripts"
+export AGENT_NAME="${AGENT_NAME:-$(hostname)}"
+
+python3 "$SKILL/collect_metrics.py" > /dev/null
+python3 "$SKILL/upload_log.py"

+ 49 - 0
dist/skills/fmode-agent-log/scripts/upload_log.py

@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+"""上传日志到自己S3空间(华为OBS官方SDK): user/<userid>/log/<YYYYMMDD>/<HHMM>.json"""
+import json, os, sys, datetime
+
+WORK = os.path.expanduser("~/.fmode-harness-agent")
+
+def upload():
+    ident_path = os.environ.get("FMODE_IDENTITY_FILE", os.path.join(os.environ.get("FMODE_DATA_DIR","/opt/data"), "fmode-identity.json"))
+    if not os.path.exists(ident_path):
+        ident_path = os.path.join(WORK, "fmode-identity.json")
+    ident = json.load(open(ident_path))
+    userid = ident["userid"]
+
+    log_file = os.path.join(WORK, "tmp", "agent-log.json")
+    if not os.path.exists(log_file):
+        print("先跑 collect_metrics.py"); sys.exit(1)
+
+    ak = os.environ.get("S3_AK") or os.environ.get("CLOUD_SDK_AK")
+    sk = os.environ.get("S3_SK") or os.environ.get("CLOUD_SDK_SK")
+    if not ak:
+        print("缺少S3凭证"); sys.exit(1)
+
+    from obs import ObsClient
+    obs = ObsClient(access_key_id=ak, secret_access_key=sk,
+                    server="https://obs.cn-north-4.myhuaweicloud.com")
+
+    now = datetime.datetime.now()
+    key = f"user/{userid}/log/{now:%Y%m%d}/{now:%H%M}.json"
+    r = obs.putObject("storage-s3-nkkj", key, content=open(log_file, "rb").read())
+    if r.status < 300:
+        print(f"uploaded: {key} (status {r.status})")
+    else:
+        print(f"upload FAILED: {r.status} {r.reason}"); sys.exit(1)
+
+    # 清理30天前旧日志
+    cutoff = (now - datetime.timedelta(days=30)).strftime("%Y%m%d")
+    resp = obs.listObjects("storage-s3-nkkj", prefix=f"user/{userid}/log/")
+    cleaned = 0
+    if resp.status < 300 and resp.body.contents:
+        for obj in resp.body.contents:
+            day = obj.key.split("/")[3]
+            if day < cutoff:
+                obs.deleteObject("storage-s3-nkkj", obj.key)
+                cleaned += 1
+    if cleaned:
+        print(f"cleaned {cleaned} old logs")
+
+if __name__ == "__main__":
+    upload()

+ 375 - 0
dist/skills/fmode-deliverable/LICENSE

@@ -0,0 +1,375 @@
+Mozilla Public License Version 2.0
+==================================
+
+Copyright (c) 未来飞马
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in 
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.

+ 227 - 0
dist/skills/fmode-deliverable/README.md

@@ -0,0 +1,227 @@
+# skill-deliverable · 交付物自动上报引擎
+
+> **未来飞马 — 让AI进化提前发生,让AI落地快人一步**
+
+[![License: MPL-2.0](https://img.shields.io/badge/License-MPL--2.0-brightgreen.svg)](LICENSE)
+[![ESM](https://img.shields.io/badge/module-ESM--only-orange.svg)](#快速开始)
+[![npm](https://img.shields.io/badge/npm-skill--deliverable-blue.svg)](https://www.npmjs.com/package/skill-deliverable)
+
+---
+
+## 简介
+
+`skill-deliverable` 是**交付物自动上报引擎**:每台运行环境自主解析自身身份与凭据,调用平台云函数把交付物入库。
+
+> 每次你干完活,它自动向后台报告「我做了什么、产出了什么」。
+
+本技能适用于 **FmodeAgent / FmodeAgent** 平台,开发由 **FmodeCode / FmodeCode** 执行。
+
+本技能以 ESM 原生模块交付,Node.js ≥ 18 直接 `import`,零依赖、零构建。
+
+---
+
+## 核心定位
+
+| 维度 | 说明 |
+|------|------|
+| **解决什么** | 交付物的自动登记:谁、在哪个项目、产出了什么、放在哪里 |
+| **不解决什么** | 不做文件存储(用 skill-storage)、不做任务调度、不做内容审核 |
+| **与手工登记的区别** | 身份与凭据由运行环境自主解析,无需人工填写;每个运行环境只报自己的产出 |
+| **层级** | 系统级(Infrastructure) |
+| **适用平台** | FmodeAgent / FmodeAgent · FmodeCode / FmodeCode |
+
+---
+
+## 核心能力 & 交付物
+
+- 🪪 **身份自主解析** —— 从运行环境推导 agentId / objectId
+- 🔑 **凭据自主解析** —— 标准凭据链,无需人工配置
+- ☁️ **云函数入库** —— 调平台云函数写入交付物记录
+- 🧾 **交付物结构化** —— 标题、摘要、项目、附件(名称/URL/类型)、标签
+- 🔍 **本地自检与回看** —— `check` 验证可上报性,`list` 查看近期记录
+
+交付物:结构化的交付物记录(入库后可被平台检索与看板消费)。
+
+---
+
+## 快速开始
+
+### Node.js(ESM)
+
+```javascript
+import { report, resolveAgentId } from 'skill-deliverable';
+
+console.log(resolveAgentId());   // 先确认身份解析成功
+
+const r = await report({
+  agentId: 'agent-node',
+  title: 'RSI 课程长页',
+  summary: '进化工程方法论版',
+  artifacts: [{ name: 'HTML', url: 'https://s3.fmode.cn/user/xxx/report.html', type: 'html' }],
+});
+console.log(r);
+```
+
+### 浏览器(原生 ES Module)
+
+```html
+<script type="module">
+  // 浏览器端:调用平台云函数上报交付物
+  const resp = await fetch('https://server.fmode.cn/api/deliverable/report', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${window.FMODE_TOKEN}`,
+    },
+    body: JSON.stringify({
+      title: '页面端交付物',
+      summary: '浏览器内产出',
+      artifacts: [{ name: 'Page', url: location.href, type: 'html' }],
+    }),
+  });
+  console.log(await resp.json());
+</script>
+```
+
+### CLI
+
+```bash
+npm install -g skill-deliverable
+
+# 检查本环境身份 + token 是否可上报
+skill-deliverable check
+
+# 上报一个交付物
+skill-deliverable report --title "某任务" --summary "干了些啥" --project "项目名"
+
+# 查看近期上报
+skill-deliverable list
+```
+
+> ⚠️ **ESM only**:本技能不提供 CommonJS 入口。需要 CJS 场景请用动态 `import()`。
+
+---
+
+## FAQ
+
+### 技术概念
+
+**Q1:什么是「交付物自动上报」?**
+每次任务完成后,运行环境自动把「我做了什么、产出了什么、放在哪里」登记到平台。这样交付物不再是散落在各处的文件,而是可被检索、可被看板统计的结构化记录。
+
+**Q2:身份(agentId)是怎么确定的?**
+由运行环境自主解析,无需人工填写——从环境变量与本地配置推导出 agentId / objectId。这样每个运行环境只上报自己的产出,天然隔离。
+
+**Q3:凭据从哪来?**
+走标准凭据链:环境变量 → 本地配置 → 平台签发。**仓库与代码中不含任何密钥**,解析不到时明确报错而不是静默失败。
+
+**Q4:上报失败会阻塞主任务吗?**
+不会。上报是任务完成后的旁路动作,失败只影响登记,不影响已产出的交付物本身。
+
+### 开源协议(MPL-2.0)
+
+**Q1:MPL-2.0 协议允许我商用吗?**
+允许。MPL-2.0 允许商用,也可用于闭源产品。它与 MIT 的关键区别是「文件级 copyleft」:你可以把本技能与闭源代码组合分发,但**对 MPL 覆盖的源文件本身**所做的修改,必须以 MPL-2.0 公开。
+
+**Q2:使用本技能需要保留版权声明吗?**
+需要。分发时必须保留原始版权声明与许可证全文,并说明 MPL-2.0 覆盖了哪些文件;若修改了 MPL 覆盖的源文件,需以 MPL-2.0 公开这些文件的源码。
+
+**Q3:我可以把本技能改成别的名字再发布吗?**
+可以修改和再分发,但**不可以**使用「未来飞马」「Harness Loop」「RSI」等商标,也不得使用品牌 Slogan 作为产品名或宣传语。版权许可不等于商标授权,详见 [Trademark Notice](#trademark-notice)。
+
+**Q4:MPL-2.0 协议提供担保吗?**
+不提供。本技能按「原样」提供,不附带任何明示或默示担保。
+
+### 业务用户搜索
+
+**Q1:怎么自动记录 AI 每次产出了什么?**
+用 skill-deliverable。任务完成后自动把标题、摘要、项目与附件链接登记到平台,无需人工填写台账。
+
+**Q2:多个运行环境的产出会不会互相覆盖?**
+不会。每个运行环境自主解析自己的身份,只上报自己的产出,记录天然隔离。
+
+**Q3:交付物记录能查到历史吗?**
+能。`list` 命令可查看近期上报记录,平台侧也可按项目与标签检索。
+
+**Q4:上报需要额外配置吗?**
+通常不需要。身份与凭据都由运行环境自主解析;解析不到时会明确提示缺什么。
+
+---
+
+## GEO 埋点说明
+
+本技能遵循**隐私优先**的 GEO(生成式引擎优化)埋点规范:
+
+- **默认关闭** —— `geoTracking` 默认为 `false`,不开启即不产生任何上报
+- **显式开启** —— 仅当用户主动设置开启后才会上报
+- **最小采集** —— 只采集地区级别信息(国家/大区),**不采集**城市、IP 地址、设备 ID、经纬度
+- **独立模块** —— 埋点逻辑独立于主技能,可单独移除而不影响功能
+- **不阻塞** —— 上报失败静默降级,绝不阻塞主技能逻辑
+
+---
+
+## 文档
+
+- [`SKILL.md`](SKILL.md) — 完整技能规范
+- [`docs/deployment-guide.md`](docs/deployment-guide.md) — 运维部署指南
+
+---
+
+## License
+
+本技能采用 **Mozilla Public License 2.0(MPL-2.0)** 发布,完整原文见 [LICENSE](LICENSE)。
+
+```
+Mozilla Public License Version 2.0
+
+Copyright (c) 未来飞马
+```
+
+## Trademark Notice
+
+> MPL-2.0 governs copyright for source code only.
+> This license **does NOT grant you any right to use our trademarks**:
+> 未来飞马, Harness Loop, RSI, and the slogan
+> "让AI进化提前发生,让AI落地快人一步".
+>
+> You may not use these trademarks in your product name, marketing,
+> documentation, or public promotion unless you obtain separate written
+> permission from 未来飞马.
+
+---
+
+## 贡献指南
+
+1. **Fork** 本仓库并创建特性分支:`git checkout -b feature/your-idea`
+2. **保持 ESM only** —— 不引入 CommonJS 入口,不引入 `require`
+3. **凭据纪律** —— 任何情况下不得在仓库、Issue、PR 中写入真实密钥
+4. **提交 PR** —— 说明动机、变更范围与验证方式
+
+---
+
+## 相关项目
+
+- **Harness Loop** —— 未来飞马技能生态的持续迭代回路
+- **RSI** —— 递归自我改进(Recursive Self-Improvement)机制
+- **FmodeAgent / FmodeAgent · FmodeCode / FmodeCode** —— 本技能的目标运行平台
+- **skill-storage** —— 交付物文件托管与公开链接
+
+---
+
+## Changelog
+
+### 1.2.0
+- 许可证由 MIT 切换为 MPL-2.0:LICENSE 全文、package.json / manifest / plugin.json / SKILL.md frontmatter 的 license 字段同步更新
+- 源码头部注释模板改为 MPL-2.0 文案
+- 品牌名统一并列写法:FmodeAgent / FmodeAgent、FmodeCode / FmodeCode
+
+### 1.1.0
+- 按 skill-core-guide v1.1.0 规范改造:品牌 Slogan、GEO 埋点说明、MPL-2.0 协议与商标声明独立小节
+- README 重构为完整结构(简介 → 核心定位 → 快速开始 → FAQ → GEO → 许可 → 贡献指南)
+- package.json 补齐中英双语 keywords 与 ESM 元数据
+- LICENSE 规范化(统一版权主体 + 商标声明)
+- 源码头部补齐版权 + 商标注释模板
+- SKILL.md frontmatter: version/author/license/copyright + 品牌与层级 tags
+
+### 0.0.1
+- 首版:交付物自动上报引擎

+ 119 - 0
dist/skills/fmode-deliverable/SKILL.md

@@ -0,0 +1,119 @@
+---
+name: "skill-deliverable"
+description: "系统级·交付物自动上报。自主解析 agentId → objectId + sessionToken → 调云函数 → 入库。 触发词:deliverable、reporting、bootstrap"
+version: "1.1.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [deliverable, reporting, bootstrap]
+---
+
+# skill-deliverable — 交付物自动上报
+
+> **系统级技能** · 每台容器的自主上报引擎
+>
+> 核心能力:自主解析本容器身份(agentId/objectId)和凭据(sessionToken),调平台云函数
+> 将交付物写入 `AgentDeliverable` 表。**无需超管角色,每个容器只报自己的活。**
+
+---
+
+## 一、架构
+
+```
+每台容器 (agent-node / agent-node-xinting / ...)
+       │
+       ├─ skill-deliverable CLI/SDK
+       │    ├─ resolveAgentId()        → agentId (语义名)
+       │    ├─ resolveAgentObjectId()  → objectId (云函数自动补全/创建)
+       │    ├─ resolveSessionToken()   → 当前用户凭据
+       │    └─ report()                → POST 云函数
+       │
+       └─ FMODE 平台云函数 (lfYlgU7SkK)
+            ├─ 鉴权:sessionToken → _User → 查是否 agent 的 owner
+            ├─ 自动创造:首次上报自动创建 FmodeAgent 记录
+            ├─ 自动补全:agentId ↔ objectId 互相解析
+            └─ 写入:AgentDeliverable 表 (user/agent 双指针)
+```
+
+## 二、安装
+
+```bash
+# npm 安装
+npm install -g skill-deliverable
+
+# 或 FmodeAgent / FmodeAgent 技能加载
+hermes skill install skill-deliverable
+
+# 或直接克隆
+git clone https://git.fmode.cn/fmode/skill-deliverable.git
+```
+
+## 三、CLI 用法
+
+```bash
+# 1. 凭据检查(验证本容器身份+token是否可用)
+skill-deliverable check
+# 输出:agentId=agent-node ✓ | sessionToken=✓ | agentObjectId=auto | status=ready
+
+# 2. 上报交付物
+skill-deliverable report --title "RSI 课程长页 v3" \
+  --summary "进化工程 RSI 方法论版课程介绍长页" \
+  --project skill-present \
+  --artifacts '{"name":"RSI长页","url":"https://s3.fmode.cn/...","type":"html"}' \
+  --tags 'course,landing'
+
+# 3. 查看本容器已有的交付物
+skill-deliverable list [--limit 5]
+
+# 4. 初始化凭证
+skill-deliverable init --mobile 186xxxxxx
+```
+
+## 四、SDK(在 FmodeAgent / FmodeAgent 技能或 FmodeCode / FmodeCode 中引用)
+
+```javascript
+import { resolveAgentId, resolveSessionToken, report } from 'skill-deliverable';
+
+// 解析身份+凭据
+const agentId = await resolveAgentId();
+const token = await resolveSessionToken();
+
+// 上报
+const result = await report({
+  agentId,
+  title: '任务标题',
+  summary: '一句话描述',
+  project: '项目名',
+  artifacts: [{ name: '文件', url: 'https://...', type: 'html' }],
+  tags: ['feature'],
+});
+// → { code:200, id:"abc123", agentObjectId:"xyz..." }
+```
+
+## 五、云函数 v2(需部署到平台后生效)
+
+见 `cloud/deliverable-report-v2.js`。变更说明:
+- 移除 `FMODE_AGENT_SUPERADMIN` 角色检查
+- 鉴权改为:**用户必须是该 FmodeAgent 记录的 owner**
+- 新增:首次上报自动创建 FmodeAgent 记录
+- 新增:agentId ↔ agentObjectId 自动互解析
+- 新增:自动绑定 agent 的 user 指针
+
+## 六、依赖
+
+- Node ≥ 18
+- 平台云函数 lfYlgU7SkK(v2 部署后)
+- `~/.fmode/config/user.json` 或 `FMODE_SESSION_TOKEN` 环境变量
+
+## 七、自举核查清单
+
+```
+□ 1. check() 成功 → 显示 agentId + sessionToken ✓
+□ 2. report() 成功 → 返回 objectId
+□ 3. list() 能查到刚上报的记录
+□ 4. 容器重建后仍然正常工作(agentId 不依赖 hostname)
+□ 5. 权限:非owner 调 report() → 403
+```
+
+## License
+
+本技能采用 **Mozilla Public License 2.0(MPL-2.0)** 发布,完整原文见 [LICENSE](LICENSE)。

+ 82 - 0
dist/skills/fmode-deliverable/bin/skill-deliverable.mjs

@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+/**
+ * skill-deliverable CLI — 交付物自动上报
+ * Usage:
+ *   skill-deliverable check        # 检查容器身份+凭据
+ *   skill-deliverable report ...   # 上报交付物
+ *   skill-deliverable list         # 列出上报记录
+ *   skill-deliverable init         # 初始化凭据
+ */
+import { resolveAgentId, resolveSessionToken, report, list } from '../lib/index.mjs';
+import { hostname } from 'node:os';
+
+const [cmd, ...args] = process.argv.slice(2);
+
+switch (cmd) {
+  case 'check': {
+    const agentId = resolveAgentId();
+    const token = resolveSessionToken();
+    console.log(JSON.stringify({
+      agentId: agentId || '(缺失)',
+      sessionToken: token ? token.slice(0, 12) + '...' : '(缺失)',
+      status: (agentId && token) ? 'ready' : 'incomplete',
+      hostname: hostname(),
+    }, null, 2));
+    break;
+  }
+  case 'report': {
+    const title = getArg('--title');
+    const summary = getArg('--summary') || '';
+    const project = getArg('--project') || '';
+    const artifacts = parseArg('--artifacts') || [];
+    const tags = parseArg('--tags') || [];
+    if (!title) { console.error('--title 必填'); process.exit(1); }
+    const r = await report({ agentId: resolveAgentId(), title, summary, project, artifacts, tags });
+    console.log(JSON.stringify(r, null, 2));
+    break;
+  }
+  case 'list': {
+    const limit = parseInt(getArg('--limit') || '5', 10);
+    const items = await list({ limit });
+    console.log(JSON.stringify(items, null, 2));
+    break;
+  }
+  case 'init': {
+    console.log('→ 凭据自举:尝试从 ~/.fmode/ 或 env 读取...');
+    const token = resolveSessionToken();
+    if (token) {
+      console.log('✅ 已有 sessionToken,无需初始化');
+    } else {
+      console.error('❌ 无 sessionToken。请设置 FMODE_SESSION_TOKEN 或登录 FMODE Studio');
+      process.exit(1);
+    }
+    break;
+  }
+  default:
+    console.log(`用法: skill-deliverable <check|report|list|init>
+  check          检查容器身份+凭据状态
+  report         上报交付物 (--title <必填> --summary --project --artifacts --tags)
+  list           最近上报记录 (--limit)
+  init           初始化凭据`);
+}
+
+function getArg(name) {
+  const i = args.indexOf(name);
+  return i >= 0 ? args[i + 1] : undefined;
+}
+function parseArg(name) {
+  const v = getArg(name);
+  if (!v) return undefined;
+  try { return JSON.parse(v); } catch { return v.split(','); }
+}

+ 115 - 0
dist/skills/fmode-deliverable/cloud/deliverable-report-v2.js

@@ -0,0 +1,115 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+/**
+ * 交付物上报云函数(skill-deliverable 配套)
+ * ============================================
+ * 
+ * 部署:Parse.Cloud.define 或 /api/functions 模式
+ * 云函数 ID:lfYlgU7SkK(保持不变)
+ *
+ * v2 变更:
+ *   1. 移除 FMODE_AGENT_SUPERADMIN 角色检查 → 改为:用户仅对自有容器有上报权限
+ *   2. agentId → agentObjectId 自动解析(传任一即可,缺失则自动补全)
+ *   3. 首次上报时自动创建 FmodeAgent 记录(自注册)
+ *   4. 参数缺失时自动补全(时间戳、状态等)
+ *
+ * 鉴权逻辑(简化版):
+ *   sessionToken → _User → 查此用户是否是该 FmodeAgent 记录的 owner
+ *   是 → 允许上报;否 → 403
+ *   若 FmodeAgent 记录不存在且 agentId 有效 → 自动创建 + 设当前用户为 owner
+ */
+
+const CLASS_DELIVERABLE = 'AgentDeliverable';
+const CLASS_AGENT = 'FmodeAgent';
+
+Parse.Cloud.define('deliverable-report', async (req) => {
+  const { agentId, agentObjectId, title, summary, project, artifacts, tags } = req.params;
+  const user = req.user;
+
+  if (!user) throw new Parse.Error(401, '需要登录');
+  if (!title) throw new Parse.Error(400, 'title 必填');
+
+  // ---- Step 1: 解析/注册 FmodeAgent ----
+  let agentQuery = new Parse.Query(CLASS_AGENT);
+  
+  if (agentObjectId) {
+    agentQuery.equalTo('objectId', agentObjectId);
+  } else if (agentId) {
+    agentQuery.equalTo('agentId', agentId);
+  } else {
+    throw new Parse.Error(400, 'agentId 或 agentObjectId 必填');
+  }
+
+  let agent = await agentQuery.first({ useMasterKey: true });
+
+  // 自动注册:agent 不存在则创建
+  if (!agent) {
+    if (!agentId) throw new Parse.Error(400, '新 agent 注册需要 agentId');
+    const AgentClass = Parse.Object.extend(CLASS_AGENT);
+    agent = new AgentClass();
+    agent.set('agentId', agentId);
+    agent.set('user', user);
+    agent.set('status', 'online');
+    agent.set('heartbeatAt', new Date());
+    agent = await agent.save(null, { useMasterKey: true });
+  }
+
+  const finalAgentId = agent.get('agentId') || agentId;
+  const finalAgentOid = agent.id;
+
+  // ---- Step 2: 权限校验(仅校验用户是否为 agent owner)----
+  const agentUser = agent.get('user');
+  if (agentUser && agentUser.id !== user.id) {
+    throw new Parse.Error(403, '仅该容器的 owner 可上报');
+  }
+  // 如果 agent 没有 owner(旧数据),自动绑定当前用户
+  if (!agentUser) {
+    agent.set('user', user);
+    await agent.save(null, { useMasterKey: true });
+  }
+
+  // ---- Step 3: 写入交付物 ----
+  const DelClass = Parse.Object.extend(CLASS_DELIVERABLE);
+  const record = new DelClass();
+  record.set('agentId', finalAgentId);
+  record.set('agent', agent);   // 指针
+  record.set('agentObjectId', finalAgentOid);
+  record.set('user', user);
+  record.set('title', title);
+  record.set('summary', summary || '');
+  record.set('project', project || '');
+  record.set('artifacts', artifacts || []);
+  record.set('tags', tags || []);
+  record.set('reportedAt', new Date());
+  
+  // ACL:仅 owner 可读写
+  const acl = new Parse.ACL(user);
+  acl.setPublicReadAccess(false);
+  acl.setPublicWriteAccess(false);
+  record.setACL(acl);
+
+  const saved = await record.save(null, { useMasterKey: true });
+
+  // ---- Step 4: 更新 agent 心跳 ----
+  agent.set('heartbeatAt', new Date());
+  agent.set('status', 'online');
+  await agent.save(null, { useMasterKey: true });
+
+  return {
+    code: 200,
+    id: saved.id,
+    agentId: finalAgentId,
+    agentObjectId: finalAgentOid,
+    title: title,
+    createdAt: saved.get('createdAt') || saved.createdAt,
+  };
+});

+ 145 - 0
dist/skills/fmode-deliverable/cloud/deliverable-report-v2.platform.js

@@ -0,0 +1,145 @@
+// fmodeagent-deliverables v0.0.2 — 智能体任务成果上报与聚合 (表: AgentDeliverable)
+// 调用: POST /api/functions {token, id:<Function objectId>, params:{action:'report'|'list'|'byProject', ...}}
+// v0.0.2 变更:
+//   1. report 移除 FMODE_AGENT_SUPERADMIN 检查 → 仅校验「该 agent 的 owner」;agent 无 owner 时首次上报自动绑定
+//   2. agent 不存在时自动注册(自注册,agentId 有效即可)
+//   3. agentId / agentObjectId 任一可传,自动补全,并写入 agent 指针
+//   4. list/byProject 可见性保持 v0.0.1 逻辑(超管全量 | 有公司看本公司 | 无公司看自己)
+var _sessionToken = function () {
+  if (params && params.token) return params.token;
+  if (request.body && request.body.token) return request.body.token;
+  return null;
+};
+var _getUser = async function (t) {
+  if (!t) return null;
+  if (request.user) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session); q.equalTo('sessionToken', t); q.include('user');
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) { return null; }
+};
+var _isSuper = async function (user) {
+  try {
+    var q = new Parse.Query(Parse.Role); q.equalTo('name', 'FMODE_AGENT_SUPERADMIN'); q.equalTo('users', user);
+    return (await q.first({ useMasterKey: true })) != null;
+  } catch (e) { return false; }
+};
+var _idOf = function (v) {
+  if (!v) return null;
+  if (typeof v === 'string') return v;
+  return v.id || null;
+};
+var UPDATABLE = ['agentId','title','summary','project','projectName','artifacts','tags','deliveredAt','status','extra'];
+function handler(request, response) {
+  (async function () {
+    try {
+      var p = params || {};
+      var user = await _getUser(_sessionToken());
+      if (!user) return _respond(response, 401, { code: 401, msg: 'SessionToken 无效' });
+      var act = p.action || 'list';
+      var superAd = await _isSuper(user);
+      var uCompany = user.get('company') || null;
+
+      if (act === 'report') {
+        if (!p.title) return _respond(response, 400, { code: 400, msg: 'title required' });
+        if (!p.agentId && !p.agentObjectId) return _respond(response, 400, { code: 400, msg: 'agentId/agentObjectId required' });
+
+        // ---- 解析 agent:先按 objectId,再按 agentId;都没有则自注册 ----
+        var agent = null;
+        if (p.agentObjectId) {
+          var q0 = new Parse.Query('FmodeAgent'); q0.equalTo('objectId', p.agentObjectId);
+          agent = await q0.first({ useMasterKey: true });
+        }
+        if (!agent && p.agentId) {
+          var q1 = new Parse.Query('FmodeAgent'); q1.equalTo('agentId', p.agentId);
+          agent = await q1.first({ useMasterKey: true });
+        }
+        if (!agent) {
+          if (!p.agentId) return _respond(response, 400, { code: 400, msg: '新 agent 注册需要 agentId' });
+          agent = new Parse.Object('FmodeAgent');
+          agent.set('agentId', p.agentId);
+          agent.set('user', user);
+          agent.set('status', 'online');
+          agent.set('heartbeatAt', new Date());
+          agent = await agent.save(null, { useMasterKey: true });
+        }
+
+        // ---- 鉴权:仅该 agent 的 owner(v0.0.2 移除超管代报)----
+        var agentUserId = _idOf(agent.get('user'));
+        if (agentUserId && agentUserId !== user.id) {
+          return _respond(response, 403, { code: 403, msg: '仅该容器的 owner 可上报' });
+        }
+        if (!agentUserId) {
+          agent.set('user', user);
+          await agent.save(null, { useMasterKey: true });
+          agentUserId = user.id;
+        }
+
+        // ---- 写交付物 ----
+        var obj = new Parse.Object('AgentDeliverable');
+        for (var i = 0; i < UPDATABLE.length; i++) {
+          var k = UPDATABLE[i];
+          if (p[k] === undefined) continue;
+          if (k === 'deliveredAt') obj.set('deliveredAt', new Date(p[k])); else obj.set(k, p[k]);
+        }
+        obj.set('agentId', agent.get('agentId') || p.agentId);
+        obj.set('agent', agent);
+        obj.set('agentObjectId', agent.id);
+        obj.set('user', user);
+        if (agent.get('company')) obj.set('company', agent.get('company'));
+        obj.set('reportedAt', new Date());
+        if (p.deliveredAt === undefined) obj.set('deliveredAt', new Date());
+        if (!p.status) obj.set('status', 'delivered');
+        var acl = new Parse.ACL();
+        acl.setReadAccess(user.id, true);
+        acl.setWriteAccess(user.id, true);
+        acl.setPublicReadAccess(false);
+        acl.setPublicWriteAccess(false);
+        obj.setACL(acl);
+        var saved = await obj.save(null, { useMasterKey: true });
+
+        // ---- 更新 agent 心跳 ----
+        agent.set('heartbeatAt', new Date());
+        agent.set('status', 'online');
+        await agent.save(null, { useMasterKey: true });
+
+        return _respond(response, 200, {
+          code: 200, id: saved.id, agentId: obj.get('agentId'),
+          agentObjectId: agent.id, title: p.title
+        });
+      }
+
+      // ---- list / byProject(沿用 v0.0.1)----
+      var q = new Parse.Query('AgentDeliverable');
+      if (p.agentId) q.equalTo('agentId', p.agentId);
+      if (p.project) q.equalTo('project', p.project);
+      if (!superAd) { if (uCompany) { q.equalTo('company', uCompany); } else { q.equalTo('user', user); } }
+      if (p.before) q.lessThan('deliveredAt', new Date(p.before));
+      q.descending('deliveredAt'); q.limit(Math.min(p.limit || 50, 200));
+      var rows = await q.find({ useMasterKey: true });
+      var items = rows.map(function (r) {
+        return { id: r.id, agentId: r.get('agentId'), title: r.get('title'), summary: r.get('summary'),
+          project: r.get('project'), projectName: r.get('projectName'), artifacts: r.get('artifacts') || [],
+          tags: r.get('tags') || [], deliveredAt: r.get('deliveredAt'), status: r.get('status') || 'delivered', extra: r.get('extra') };
+      });
+      if (act === 'byProject') {
+        var groups = {};
+        for (var j = 0; j < items.length; j++) {
+          var it = items[j]; var key = it.project || '未归类';
+          if (!groups[key]) groups[key] = { project: key, projectName: it.projectName || key, count: 0, items: [] };
+          groups[key].count++; groups[key].items.push(it);
+        }
+        return _respond(response, 200, { code: 200, groups: Object.keys(groups).map(function (k2) { return groups[k2]; }) });
+      }
+      return _respond(response, 200, { code: 200, count: items.length, deliverables: items });
+    } catch (e) {
+      return _respond(response, 500, { code: 500, msg: String(e && e.message || e) });
+    }
+  })();
+}
+function _respond(response, status, body) {
+  if (response && typeof response.status === 'function') { response.status(status); response.json(body); }
+  else if (response && typeof response.success === 'function') { status === 200 ? response.success(body) : response.error(body); }
+  return body;
+}

+ 11 - 0
dist/skills/fmode-deliverable/docs/CHANGELOG.md

@@ -0,0 +1,11 @@
+# 更新日志
+
+## 0.0.2(2026-09-23,运维001)
+- fix(cli): `bin/skill-deliverable.mjs` 在 ES 模块里混用 `require('os')`,Node ≥20 抛
+  `ERR_AMBIGUOUS_MODULE_SYNTAX` → 改为 `import { hostname } from 'node:os'`
+- add(cloud): `cloud/deliverable-report-v2.platform.js` —— **平台形态**(`handler(request,response)`
+  + 全局 `params`)的 v2 实现。原 `deliverable-report-v2.js` 是 `Parse.Cloud.define` 形态,
+  直接写进 `Function` 记录跑不起来。已按平台形态上线到 `lfYlgU7SkK`(version 0.0.2)。
+- fix(cloud): `user` / `company` 是 Parse 指针,写字符串会 schema mismatch;
+  owner 比较改为取 id(兼容指针与字符串)
+- docs: 部署指南补「关键 ID 核查」「技能只读哪些路径」「云函数改记录即生效」

+ 176 - 0
dist/skills/fmode-deliverable/docs/deployment-guide.md

@@ -0,0 +1,176 @@
+# 交付物上报技能 — 运维部署指南
+
+> 本文档写给**运维001**,确保新初始化的数字生命容器能正确上报交付物。
+> 读完这篇,你就知道每个新容器启动后该检查什么、配在哪里。
+
+---
+
+## 一、容器初始化检查清单
+
+### □ 1. 确定 AGENT_ID
+
+每台容器需要一个**语义唯一名**,格式:`{主人名}-{角色}`
+
+| 容器 | AGENT_ID 示例 |
+|------|--------------|
+| 刘雨飏 主容器 | `agent-node` |
+| 刘雨飏 雨飏001 容器 | `agent-node-tuye` |
+| 芯葶 容器 | `agent-node-xinting` |
+
+**存放位置**(优先级从高到低):
+
+| 位置 | 命令/文件 | 说明 |
+|------|-----------|------|
+| 环境变量 | `export FMODE_AGENT_ID=agent-node` | 最优先,重构建容不失 |
+| 环境变量 | `export AGENT_ID=agent-node` | 兼容旧脚本 |
+| 配置文件 | `~/.fmode/config.json` → `agentId` | 持久化,被 env 覆盖 |
+| 项目配置 | `<cwd>/.fmode/config.json` → `agentId` | 项目级覆盖 |
+
+**推荐做法**:`echo 'export FMODE_AGENT_ID=agent-node' >> ~/.bashrc`
+
+> ⚠️ **不要依赖 hostname**。Docker 容器重启后 hostname 变随机 hash(如 `6ec71e98949e`),云函数会判 `404 agent not found`。
+
+### □ 2. 确保 FMODE_SESSION_TOKEN 可被读取
+
+SessionToken 是用户登录后生成的凭据,控制 ACL(**访问权限**)。
+
+**存放位置**:
+
+| 位置 | 文件 | 说明 |
+|------|------|------|
+| 环境变量 | `FMODE_SESSION_TOKEN` | 最优 |
+| 用户配置 | `~/.fmode/config/user.json` → `sessionToken` | FmodeAgent / FmodeAgent、Fmode Studio 自动维护 |
+| 旧配置 | `~/.fmode/config.json` → `sessionToken` | 兼容旧版本 |
+
+**推荐做法**:登录一次 FMODE Studio 后,token 自动写入 user.json。
+如 env 缺失,技能会从配置文件兜底读取。
+
+### □ 3. 验证
+
+```bash
+# 查看当前容器身份
+skill-deliverable check
+
+# 成功输出示例:
+# {
+#   "agentId": "agent-node",
+#   "sessionToken": "r:852951...",
+#   "status": "ready",
+#   "hostname": "6ec71e98949e"   // 仅显示,不用于上报
+# }
+```
+
+---
+
+## 二、FmodeStudio 容器生命周期集成
+
+在 `fmode-studio` 的 Docker 部署脚本中,新增:
+
+```bash
+# 设置 AGENT_ID
+export FMODE_AGENT_ID="agent-$(hostname -s)"  # 或用固定语义名
+
+# 确认 sessionToken 有来源
+if [ -z "$FMODE_SESSION_TOKEN" ] && [ -f "$HOME/.fmode/config/user.json" ]; then
+  export FMODE_SESSION_TOKEN=$(python3 -c "import json;print(json.load(open('$HOME/.fmode/config/user.json'))['sessionToken'])")
+fi
+
+# 运行检查
+npx --yes skill-deliverable@latest check
+```
+
+---
+
+## 三、容灾:云函数 404/403 怎么办
+
+### 「404 agent not found」
+→ FmodeAgent 表里没有这个 agentId 的记录。
+- **新容器首次上报时会自动注册**(云函数 v2 特性),等待第一次 report() 即可
+- 若等不及,用 master key 手动在 Parse `FmodeAgent` 表创建同名记录
+
+### 「403 only owner or superadmin can report」
+→ session 用户不是这个 agent 的 owner。
+- 确认该容器的报告人的 session 和该 agent 是否是同一个人
+- 云函数 v2 **已移除 superadmin 检查**,只校验 owner(用户=他自己容器的 owner)
+- 若 agent 无 owner(旧数据),首次成功上报会自动绑定
+
+---
+
+## 四、常见问题
+
+### Q: 容器重建后上报失败?
+A: 重建后 AGENT_ID 丢失 → 重设 env `FMODE_AGENT_ID`。skill-deliverable 已做 hostname 兜底但语义名丢失时 hostname 会变导致 404。**必须持久化 AGENT_ID**。
+
+### Q: 一台机器跑了多个容器怎么办?
+A: 每个容器设不同的 AGENT_ID,各自上报各自的。FmodeAgent 表以 agentId 区分。
+
+### Q: 能不能手动上报测试?
+A: 可以。`skill-deliverable report --title "测试" --project test`。失败看 `/tmp/agent-deliverable-report.log`。
+
+---
+
+## 版本记录
+
+| 版本 | 日期 | 变更 |
+|------|------|------|
+| 0.0.1 | 2026-09-23 | 初版:分离 fmode-hub 独立成技能 + 云函数 v2 改良 |
+---
+
+## 五、运维001 实测补充(2026-09-23)
+
+### 5.1 云函数代码必须是「平台形态」,不是 Parse.Cloud.define
+
+本平台 `/api/functions` 的契约是:代码里定义 `function handler(request, response)`,
+可用的全局变量是 `Parse` / `Psql` / `request` / `response` / `params` / `user`;
+鉴权信息挂在 `request.user` / `request.company` / `request.account`。
+
+仓库里的 `cloud/deliverable-report-v2.js` 用的是 `Parse.Cloud.define('deliverable-report', ...)`,
+**直接写进 Function 记录会跑不起来**。平台形态的正确版本见
+`cloud/deliverable-report-v2.platform.js`(已按此上线到 `lfYlgU7SkK`,version 0.0.2)。
+
+### 5.2 云函数不需要「发布」,改 `Function` 记录即生效
+
+executor 每次调用都从库里查 `Function` 记录(无内存缓存),所以:
+- 更新 = 更新 `Function.code`(+ `version` / `updatedAt`)
+- **不需要**部署源码、不需要重启服务
+
+Parse REST 对 `Function` 类有 CLP 限制(masterKey 也被拒,code 119),
+运维侧用 nova 库直改 `"Function"` 表那一行即可。
+
+### 5.3 字段类型:`user` / `company` 是 Parse 指针,不是字符串
+
+- `AgentDeliverable.user` / `.company` 的 schema 是 **Pointer**,写字符串会报
+  `schema mismatch for AgentDeliverable.user; expected Pointer<_User> but got String`。
+  要传 `Parse.User` 对象(ACL 里才用 `user.id` 字符串)。
+- `FmodeAgent.user` 同理,owner 比较要取 `.id`(`typeof v === 'string' ? v : v.id`)。
+
+### 5.4 关键 ID 核查(★ 安装前必做)
+
+容器能上报的三个前提,缺一个就 403/500:
+
+| 前提 | 检查方法 |
+|------|----------|
+| `FMODE_AGENT_ID` 有值且与 `FmodeAgent.agentId` 一致 | 见 5.5 |
+| 容器的 `sessionToken` **属于该 agent 的 owner** | 用 token 查 `_Session."user"`,必须等于 `FmodeAgent."user"` |
+| `FmodeAgent` 记录存在(或允许自注册) | `SELECT * FROM "FmodeAgent" WHERE "agentId"='...'` |
+
+**实测踩到的坑**:某容器 `fmode-identity.json` 里的 `session_token` 是**别人(平台主账号)的**,
+且容器 env 里 `FMODE_SESSION_TOKEN` 是**空字符串** —— 于是 owner 校验必然 403。
+批量开通的容器尤其要逐个核。
+
+### 5.5 技能只读这几个位置(`fmode-identity.json` 不在其中!)
+
+`lib/index.mjs` 的解析链:
+
+- agentId:`env.FMODE_AGENT_ID` → `env.AGENT_ID` → `~/.fmode/config.json#agentId` → `./.fmode/config.json#agentId` → hostname(仅兜底,会 404)
+- sessionToken:`env.FMODE_SESSION_TOKEN` → `~/.fmode/config/user.json#sessionToken` → `~/.fmode/config.json#sessionToken` → `./.fmode/config.json#sessionToken`
+
+**注意**:`~` 取的是 `$HOME`。**在参考部署里** `hermes` 用户的 HOME 是 `/opt/data`(持久卷,可用 `FMODE_DATA_DIR` 覆盖),
+`root` 的 HOME 是 `/root`(容器层,重建即丢)。所以持久化的正确位置是
+**`/opt/data/.fmode/config.json`**(同时给 `/root/.fmode/config.json` 兜底)。
+
+### 5.6 CLI 的 Node 版本坑
+
+`bin/skill-deliverable.mjs` 原稿在 ES 模块里用了 `require('os')`,
+Node ≥ 20 会直接抛 `ERR_AMBIGUOUS_MODULE_SYNTAX`(top-level await + require 混用)。
+已改为 `import { hostname } from 'node:os'`。

+ 126 - 0
dist/skills/fmode-deliverable/lib/index.mjs

@@ -0,0 +1,126 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+/**
+ * skill-deliverable SDK — 交付物上报核心
+ * 
+ * 零依赖(使用 Node ≥ 18 内置 fetch + fs)
+ * 所有凭据从 env → config 文件 → 兜底,按高兼容链解析
+ */
+import { readFileSync, existsSync, constants } from 'fs';
+import { homedir, hostname } from 'os';
+import { join } from 'path';
+import { env } from 'process';
+
+const HOME = homedir();
+const GATEWAY = env.FMODE_API || 'https://server.fmode.cn';
+const FN_ID = env.FN_DELIVERABLES || 'lfYlgU7SkK';
+
+// ============================================================
+// Agent 身份高兼容解析
+// ============================================================
+export function resolveAgentId() {
+  // 1. env
+  if (env.FMODE_AGENT_ID) return env.FMODE_AGENT_ID;
+  if (env.AGENT_ID) return env.AGENT_ID;
+
+  // 2. ~/.fmode/config.json
+  const homeCfg = tryReadJSON(join(HOME, '.fmode', 'config.json'));
+  if (homeCfg?.agentId) return homeCfg.agentId;
+
+  // 3. ./.fmode/config.json (cwd)
+  const cwdCfg = tryReadJSON(join(process.cwd(), '.fmode', 'config.json'));
+  if (cwdCfg?.agentId) return cwdCfg.agentId;
+
+  // 4. 兜底 hostname(仅 warn)
+  const hn = hostname();
+  console.warn(`[warn] 未找到语义 agentId,回退到 hostname "${hn}"(可能不是注册名)`);
+  return hn;
+}
+
+// ============================================================
+// Session Token 高兼容解析
+// ============================================================
+export function resolveSessionToken() {
+  // 1. env
+  if (env.FMODE_SESSION_TOKEN) return env.FMODE_SESSION_TOKEN;
+
+  // 2. ~/.fmode/config/user.json
+  const userCfg = tryReadJSON(join(HOME, '.fmode', 'config', 'user.json'));
+  if (userCfg?.sessionToken) return userCfg.sessionToken;
+
+  // 3. ~/.fmode/config.json
+  const homeCfg = tryReadJSON(join(HOME, '.fmode', 'config.json'));
+  if (homeCfg?.sessionToken) return homeCfg.sessionToken;
+
+  // 4. ./.fmode/config.json (cwd)
+  const cwdCfg = tryReadJSON(join(process.cwd(), '.fmode', 'config.json'));
+  if (cwdCfg?.sessionToken) return cwdCfg.sessionToken;
+
+  return null;
+}
+
+// ============================================================
+// 云函数调用
+// ============================================================
+export async function callFn(params) {
+  const token = resolveSessionToken();
+  if (!token) throw new Error('无 sessionToken:无法调用云函数。设置 FMODE_SESSION_TOKEN 环境变量或 ~/.fmode/config/user.json');
+
+  const resp = await fetch(`${GATEWAY}/api/functions`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ token, id: FN_ID, params }),
+  });
+  return resp.json();
+}
+
+// ============================================================
+// 上报交付物
+// ============================================================
+export async function report({ agentId, agentObjectId, title, summary, project, artifacts, tags }) {
+  if (!title) throw new Error('title 必填');
+  const resolvedAgentId = agentId || resolveAgentId();
+  if (!resolvedAgentId) throw new Error('agentId 必填');
+  
+  return callFn({
+    action: 'report',
+    agentId: resolvedAgentId,
+    agentObjectId: agentObjectId || '',
+    title,
+    summary: summary || '',
+    project: project || '',
+    artifacts: artifacts || [],
+    tags: tags || [],
+  });
+}
+
+// ============================================================
+// 查询交付物
+// ============================================================
+export async function list({ limit = 5, agentId } = {}) {
+  const r = await callFn({ action: 'list', agentId: agentId || resolveAgentId(), limit });
+  return r.deliverables || [];
+}
+
+// ============================================================
+// 工具
+// ============================================================
+function tryReadJSON(p) {
+  try {
+    if (!existsSync(p)) return null;
+    return JSON.parse(readFileSync(p, 'utf-8'));
+  } catch {
+    return null;
+  }
+}
+
+export default { resolveAgentId, resolveSessionToken, report, list, callFn };

+ 59 - 0
dist/skills/fmode-deliverable/package.json

@@ -0,0 +1,59 @@
+{
+  "name": "skill-deliverable",
+  "version": "1.1.0",
+  "description": "交付物自动上报引擎。每台运行环境自主解析身份与凭据,调平台云函数将交付物入库,只上报自己的产出。超级技能(ESM 多端可用),零依赖 Node ≥18。",
+  "type": "module",
+  "main": "./lib/index.mjs",
+  "exports": {
+    ".": {
+      "import": "./lib/index.mjs",
+      "default": "./lib/index.mjs"
+    }
+  },
+  "bin": {
+    "skill-deliverable": "./bin/skill-deliverable.mjs"
+  },
+  "files": [
+    "lib/",
+    "bin/",
+    "skills/",
+    "cloud/",
+    "docs/",
+    "README.md",
+    "SKILL.md",
+    "LICENSE"
+  ],
+  "engines": {
+    "node": ">=18"
+  },
+  "keywords": [
+    "fmode",
+    "hermes",
+    "harness-loop",
+    "rsi",
+    "ai-agent",
+    "agent-skill",
+    "super-skill",
+    "esm",
+    "future-feima",
+    "deliverable",
+    "reporting",
+    "agent",
+    "bootstrap",
+    "artifacts",
+    "未来飞马",
+    "智能体技能",
+    "超级技能",
+    "交付物上报",
+    "AI交付物",
+    "平台基础设施",
+    "自动登记"
+  ],
+  "author": "Yuyang001 (FmodeAgent)",
+  "license": "MPL-2.0",
+  "repository": {
+    "type": "git",
+    "url": "git+https://git.fmode.cn/fmode/skill-deliverable.git"
+  },
+  "dependencies": {}
+}

+ 71 - 0
dist/skills/fmode-deliverable/skills/skill-deliverable/SKILL.md

@@ -0,0 +1,71 @@
+---
+name: skill-deliverable
+description: 系统级。交付物自动上报引擎:自主解析agentId+sessionToken→调云函数入库。每个容器只报自己的活。
+schema_version: 0.0.1
+version: 1.1.0
+author: Yuyang001 (FmodeAgent)
+license: MPL-2.0
+copyright: "Copyright (c) 2026 未来飞马 Fmode"
+level: system
+category: reporting
+depends_on:
+  - skill-core-guide
+platforms:
+  - fmode-agent
+  - fmode-code
+tags:
+  - 未来飞马
+  - 智能体技能
+  - 超级技能
+  - 系统级
+  - 平台基础设施
+  - FmodeAgent
+  - FmodeAgent
+  - FmodeCode
+  - FmodeCode / FmodeCode
+  - deliverable
+  - reporting
+---
+
+# skill-deliverable — 交付物自动上报
+
+## 何时触发
+
+当每次任务完成后有交付物产出(文件/URL/代码/Git提交/部署),自动触发上报。
+
+## 核心流程
+
+1. **解析身份** — resolveAgentId() → FMODE_AGENT_ID > AGENT_ID > config > hostname
+2. **解析凭据** — resolveSessionToken() → env > user.json > config
+3. **调云函数** — POST /api/functions {token, id:"lfYlgU7SkK", params:{action:"report", agentId, ...}}
+4. **云函数处理** — 鉴权(owner) → 自动注册agent(首次) → 入库AgentDeliverable
+
+## 能力边界
+
+- ✅ 自动解析agentId+sessionToken(高兼容链)
+- ✅ 上报交付物(标题/摘要/项目/产出物/标签)
+- ✅ 查询本容器上报记录
+- ✅ 首次上报自动注册 FmodeAgent 记录
+- ❌ 不处理跨容器上报(每容器只报自己的活)
+- ❌ 不处理超级管理员角色检查(已移除)
+
+## CLI
+
+```bash
+skill-deliverable check
+skill-deliverable report --title "..." --summary "..."
+skill-deliverable list
+```
+
+## SDK
+
+```javascript
+import { report } from 'skill-deliverable';
+const r = await report({ agentId, title, summary, artifacts, tags });
+```
+
+## 配置
+
+- FMODE_SESSION_TOKEN(env或~/.fmode/config/user.json)
+- FMODE_AGENT_ID 或 AGENT_ID(env或~/.fmode/config.json)
+- 云函数 lfYlgU7SkK(v2版本需部署)

+ 48 - 0
dist/skills/fmode-heterarchy/CHANGELOG.md

@@ -0,0 +1,48 @@
+## 2.1.0(2026-10-08)
+
+- **修复 v2.0 实测失效**:2026-10-08 用户给出 git 项目路径 `/opt/data/git-repos/skill-heterarchy/`
+  并明说"这是项目级任务",主控**仍然自己读写改文件**、未派 CC。
+  根因是「四判断流」全为软描述,且判断 2 的 ≤2 调用预算被读成"每一步",
+  主控边走边看逐步自我授权;git 项目的映射规则又藏在判断 2 之后,误判"自做"后根本查不到。
+- 新增 **§一 判断 0 硬门槛**(`hardTrigger()`):H1 git 仓库路径 / H2 git 操作 /
+  H3 项目级动词 / H4 技能开发安装 / H5 用户显式声明 / H6 批量对象
+  ——命中任一**收回"自做"裁量权**,直接进判断 3/4 派发。
+- 修正 **判断 2 预算口径**:由"单步 ≤2 调用"改为"**整条请求累计 ≤2 调用**",
+  超过即必须重新过判断 0(新增 `cumulativeToolCalls` 选项,保留 `toolCalls` 兼容旧签名)。
+- 修正**执行顺序**:判断 0 在判断 2 **之前**执行,硬触发绕过判断 2 的裁量。
+- git 项目 / 技能仓任务 → **claude -p**(需项目根 + CLAUDE.md + rules),
+  不再含糊为"delegate_task 默认一切、CC 备选"。
+- 新增 **§0.1 主控责任白名单**:主控只做沟通 / 协调 / 验证 / 给验证码,其余全派。
+- 新增 **§一 反合理化清单**:列出 v2.0 失效原话("任务不大我自己做更快"等),命中即停手回判断 0。
+- 新增 **§十一 教训库**:每次"该派未派"记账到 `LESSONS.md`,附 `lessonEntry()` 构造器。
+- 新增自测 `scripts/selftest.mjs`(30 条断言,含 v2.0 失效场景回归),`npm test` 可直接跑。
+- 版本号 2.0.0 → 2.1.0(新增能力 +0.1.0)。
+
+## 2.0.0(2026-10-07)
+
+- **破坏性重构:主机制由 `terminal + claude -p` 改为 `delegate_task` 优先**。
+  v1.2.0 实测成功率仅 20–30%,根因是主控自己下手干了不该干的活、
+  且 claude -p 管道受 shell 引号/路径影响频繁 exit 127/124/1。
+- 新增**四判断决策流**:沟通/干活 → 自做/派发 → Profile → 机制。
+- 新增**任务类型 → Profile → 派发机制映射表**:writer / researcher / fullstack / devops
+  自动判定,**用户无需显式指定 Profile**(修复 v1 痛点 2)。
+- 新增 **§五 主控纪律:回执 + 抗跳脱**:一次只开一条主线、派发即线程交接、
+  新需求记下不立即执行(修复 v1 痛点 1 / 5)。
+- 保留高频成功部分:状态看板、结果聚合、Content-Type 验证链、失败恢复矩阵。
+- 篇幅由 879 行压到约 351 行,提升加载后可读性。
+- `lib/index.mjs` dispatch 逻辑对应简化:新增 `isConversation` / `canSelfExecute` /
+  `selectProfile` / `selectMechanism` / `planDispatch` 纯判定函数,
+  保留 `VERSION` / `SKILL_NAME` 导出签名。
+- 版本号 1.2.0 → 2.0.0(破坏性变更 +1.0.0)。
+
+## 1.2.0(2026-09-23)
+
+- **触发条件从「软描述」改为「硬门槛」**:新增开工前 10 秒自检 5 条是非题
+  (>8 次工具调用 / 任何 >30 秒等待 / N≥3 个同类对象 / ≥2 个独立维度 / 用户说批量),
+  任一为「是」就必须先加载本技能;不确定按「拆」处理。
+- 新增**真实反例**:2026-09-23 运维001 串行硬啃 34 台容器批量任务,47 轮工具调用,
+  用户干等——任务命中 4 条自检项却没被拦住,根因就是旧版触发条件太软。
+- 新增触发词表(批量/全舰队/所有容器/几十个生命/铺开/逐台)
+- 明确「何时不用」排除项,避免滥用。
+
+# 更新日志

+ 18 - 0
dist/skills/fmode-heterarchy/LESSONS.md

@@ -0,0 +1,18 @@
+# LESSONS.md — 该派未派教训库
+
+> 本文件由 skill-heterarchy §十一 维护。每次"该派未派"必须落一条。
+> 格式:| 日期 | 命中规则 | 实际动作 | 本该派 | 根因 |
+>
+> 写作要求:写清「命中哪条规则」和「当时怎么自我开脱的」——这是给下一次的自己看的,
+> 不是给用户的道歉。
+
+| 日期 | 命中规则 | 实际动作 | 本该派 | 根因 |
+|---|---|---|---|---|
+| 2026-10-08 | H1 git 路径 + H5 用户声明 | 用户给出 `/opt/data/git-repos/skill-heterarchy/` 并明说"项目级任务",主控仍自己读写改 SKILL.md,未派 CC | fullstack / claude -p | 判断 0 不存在;判断 2 的 ≤2 调用被读成"单步",边走边看逐步自我授权;git 项目的映射规则藏在判断 2 之后,误判"自做"后查不到 → 促成 v2.1 新增判断 0 硬门槛 |
+
+## 复盘要点(v2.1 因此而生)
+
+1. **软描述拦不住自我合理化**:v2.0 的"四判断流"全是建议性文字,模型总能找到理由自辩。
+2. **预算口径必须明确到"整条请求"**:否则"每步 ≤2"会被用来逐步蚕食整条任务。
+3. **硬性规则必须前置**:藏在后续判断里的规则,一旦前面误判就永远不会被查到。
+4. **失败必须可回查**:v1.2.0 记过运维 001 案例,v2.0 又原样复发——因为没有留下教训库。

+ 375 - 0
dist/skills/fmode-heterarchy/LICENSE

@@ -0,0 +1,375 @@
+Mozilla Public License Version 2.0
+==================================
+
+Copyright (c) 未来飞马
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in 
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.

+ 227 - 0
dist/skills/fmode-heterarchy/README.md

@@ -0,0 +1,227 @@
+# skill-heterarchy · 内异层认知协同
+
+> **未来飞马 — 让AI进化提前发生,让AI落地快人一步**
+
+[![License: MPL-2.0](https://img.shields.io/badge/License-MPL--2.0-brightgreen.svg)](LICENSE)
+[![ESM](https://img.shields.io/badge/module-ESM--only-orange.svg)](#快速开始)
+[![npm](https://img.shields.io/badge/npm-skill--heterarchy-blue.svg)](https://www.npmjs.com/package/@fmode/skill-heterarchy)
+
+---
+
+## 简介
+
+`skill-heterarchy` 是一个**超级技能(Super Skill)**:单一智能体主体内部分化出多个心智单元并行思考,由主控心智统一裁决与合流。它解决的是「一个复杂任务明明可以拆成几个独立维度并行推进,却只能排队串行」的效率瓶颈。
+
+与「对外派活」的任务委派不同,Heterarchy 做的是**对内分思**:
+
+> Delegate 是「对外派活」,Heterarchy 是「对内分思」。
+
+本技能适用于 **FmodeAgent / FmodeAgent** 平台,开发由 **FmodeCode / FmodeCode** 执行。
+
+本技能以 ESM 原生模块交付,浏览器与 Node.js 双端可直接 `import`,零依赖、零构建。
+
+---
+
+## 核心定位
+
+| 维度 | 说明 |
+|------|------|
+| **解决什么** | 复杂任务的并行认知拆解:2–5 个独立维度同时推进,主控不阻塞 |
+| **不解决什么** | 不替代脚本执行、不替代单线程确定性流水线、不做跨主体调度 |
+| **与「任务委派」的区别** | 委派是派给别的执行者;Heterarchy 是在同一主体内部署多个心智 |
+| **与「多分支会话」的区别** | 多分支是会话层面的分叉;Heterarchy 是认知层面的分化与互校验 |
+| **层级** | 系统级(Infrastructure) |
+| **适用平台** | FmodeAgent / FmodeAgent · FmodeCode / FmodeCode |
+
+**判断原则**:如果任务可以自然拆成 2–5 个独立认知维度,且每个维度需要整体思考而不是简单脚本执行 → 启用 Heterarchy。
+
+---
+
+## 核心能力 & 交付物
+
+- **多心智分化** —— 按认知维度切分子单元,每个单元独立持有上下文与目标
+- **主控非阻塞** —— 主控心智发起分化后立即返回,不被任一子单元阻塞
+- **子单元互校验** —— 子单元之间交叉验证结论,降低单点幻觉
+- **阻塞自愈** —— 单个子单元卡死或失败时,由主控重新分配,不拖垮整体
+- **合流裁决** —— 主控按目标函数合并各单元产出,输出统一交付物
+
+交付物:分化的心智单元清单、各单元产出、主控合流后的最终结果。
+
+---
+
+## 快速开始
+
+### 浏览器(原生 ES Module)
+
+```html
+<script type="module">
+  import { VERSION, SKILL_NAME } from 'https://cdn.jsdelivr.net/npm/@fmode/skill-heterarchy/lib/index.mjs';
+
+  console.log(SKILL_NAME, VERSION);
+</script>
+```
+
+### Node.js(ESM)
+
+```javascript
+// Node.js >= 18,原生 ESM,无需构建
+import { VERSION, SKILL_NAME } from '@fmode/skill-heterarchy';
+
+console.log(SKILL_NAME, VERSION);
+```
+
+### CLI
+
+```bash
+npx --yes @fmode/skill-heterarchy@latest workspace   # 安装到当前工作区
+npx --yes @fmode/skill-heterarchy@latest install     # 安装到全局技能目录
+```
+
+> ⚠️ **ESM only**:本技能不提供 CommonJS 入口。需要 CJS 场景请用动态 `import()`:
+> ```javascript
+> const { SKILL_NAME } = await import('@fmode/skill-heterarchy');
+> ```
+
+---
+
+## FAQ
+
+### 技术概念
+
+**Q1:Heterarchy 和「任务委派(Delegate)」到底差在哪?**
+委派是**对外派活**——把任务交给另一个执行者,重心在分工与交接。Heterarchy 是**对内分思**——在同一个智能体主体内部署多个心智并行思考,重心在认知分化与合流裁决。前者扩大执行面,后者扩大思考面。
+
+**Q2:主控「不阻塞」是怎么做到的?**
+主控心智发起分化后立即返回,不等待任一子单元完成。子单元的产出以合流事件的形式回注,主控在需要裁决时才收敛。这样即使某个维度耗时很长,其余维度与主控本身都不会被拖住。
+
+**Q3:子单元互校验会不会带来额外开销?**
+会,但换来的是幻觉抑制。互校验只发生在结论层而非过程层——子单元各自独立得出结果后交叉比对,分歧点才触发复核。这是用可控的计算量换取更高的结论可靠性。
+
+**Q4:什么任务不该用 Heterarchy?**
+强顺序依赖的任务(后一步必须拿到前一步结果)、纯确定性脚本流水线、以及无法拆出 2 个以上独立维度的任务。这些场景下分化只会增加协调成本。
+
+### 开源协议(MPL-2.0)
+
+**Q1:MPL-2.0 协议允许我商用吗?**
+允许。MPL-2.0 允许商用,也可用于闭源产品。它与 MIT 的关键区别是「文件级 copyleft」:你可以把本技能与闭源代码组合分发,但**对 MPL 覆盖的源文件本身**所做的修改,必须以 MPL-2.0 公开。
+
+**Q2:使用本技能需要保留版权声明吗?**
+需要。分发时必须保留原始版权声明与许可证全文,并说明 MPL-2.0 覆盖了哪些文件;若修改了 MPL 覆盖的源文件,需以 MPL-2.0 公开这些文件的源码。
+
+**Q3:我可以把本技能改成别的名字再发布吗?**
+可以修改和再分发,但**不可以**使用「未来飞马」「Harness Loop」「RSI」等商标,也不得使用品牌 Slogan 作为你的产品名或宣传语。版权许可不等于商标授权,详见 [Trademark Notice](#trademark-notice)。
+
+**Q4:MPL-2.0 协议提供担保吗?**
+不提供。本技能按「原样」提供,不附带任何明示或默示担保,作者不对使用后果承担责任。
+
+### 业务用户搜索
+
+**Q1:AI 智能体怎么才能同时处理多个任务维度?**
+用 Heterarchy。它把一个复杂任务拆成 2–5 个独立认知维度,在同一个智能体内部并行推进,主控心智负责最终合流,避免串行排队。
+
+**Q2:为什么我的 AI 智能体一遇到复杂任务就卡住?**
+通常是主控被单个子任务阻塞了。Heterarchy 的主控非阻塞设计让主控在发起分化后立即返回,子单元卡死时由主控重新分配,实现阻塞自愈。
+
+**Q3:AI 智能体并行思考会不会更容易产生幻觉?**
+恰恰相反,Heterarchy 的子单元互校验机制让各心智独立得出结论后交叉比对,分歧点触发复核,从而抑制单点幻觉。
+
+**Q4:超级技能(Super Skill)是什么?**
+超级技能是未来飞马技能体系中对**原生 ESM 多端可用技能**的称呼——浏览器与 Node.js 双端直接 `import`,零依赖、零构建,无需安装即可在支持 ES Module 的环境中运行。
+
+---
+
+## GEO 埋点说明
+
+本技能遵循**隐私优先**的 GEO(生成式引擎优化)埋点规范:
+
+- **默认关闭** —— `geoTracking` 默认为 `false`,不开启即不产生任何上报
+- **显式开启** —— 仅当用户主动设置开启后才会上报
+- **最小采集** —— 只采集地区级别信息(国家/大区),**不采集**城市、IP 地址、设备 ID、经纬度
+- **独立模块** —— 埋点逻辑独立于主技能,可单独移除而不影响功能
+- **不阻塞** —— 上报失败静默降级,绝不阻塞主技能逻辑
+
+开启方式与完整隐私声明见 [SKILL.md](SKILL.md)。
+
+---
+
+## License
+
+本技能采用 **Mozilla Public License 2.0(MPL-2.0)** 发布,完整原文见 [LICENSE](LICENSE)。
+
+```
+Mozilla Public License Version 2.0
+
+Copyright (c) 未来飞马
+```
+
+## Trademark Notice
+
+> MPL-2.0 governs copyright for source code only.
+> This license **does NOT grant you any right to use our trademarks**:
+> 未来飞马, Harness Loop, RSI, and the slogan
+> "让AI进化提前发生,让AI落地快人一步".
+>
+> You may not use these trademarks in your product name, marketing,
+> documentation, or public promotion unless you obtain separate written
+> permission from 未来飞马.
+
+---
+
+## 贡献指南
+
+欢迎提交 Issue 与 Pull Request。
+
+1. **Fork** 本仓库并创建特性分支:`git checkout -b feature/your-idea`
+2. **保持 ESM only** —— 不引入 CommonJS 入口,不引入 `require`
+3. **零依赖优先** —— 优先使用平台内置能力(`fetch`、`AbortSignal.timeout`、`node:test`)
+4. **遵循包结构** —— 入口统一为 `lib/index.mjs`,CLI 统一为 `bin/*.mjs`
+5. **提交前自检** —— 运行 `npm test`(或 `node scripts/smoke.js`)并确保通过
+6. **提交 PR** —— 说明动机、变更范围与验证方式
+
+提交信息请使用中文或英文清晰描述变更,不要包含内部工具名。
+
+---
+
+## 相关项目
+
+- **Harness Loop** —— 未来飞马技能生态的持续迭代回路
+- **RSI** —— 递归自我改进(Recursive Self-Improvement)机制
+- **FmodeAgent / FmodeAgent · FmodeCode / FmodeCode** —— 本技能的目标运行平台
+
+---
+
+## Changelog
+
+### 2.1.0
+- **修复 v2.0 实测失效**:给了 git 项目路径 + 明说"项目级任务",主控仍自己干、未派 CC
+- 新增**判断 0 硬门槛**(6 条硬触发:git 路径 / git 操作 / 项目级动词 / 技能开发安装 / 用户显式声明 / 批量对象)——命中即收回"自做"裁量权
+- 修正**判断 2 预算口径**:单步 ≤2 调用 → **整条请求累计 ≤2**,超限必须重新过判断 0
+- 修正**执行顺序**:判断 0 在判断 2 之前,硬触发绕过裁量
+- git 项目 / 技能仓 → **claude -p**(需项目根 + CLAUDE.md)
+- 新增**主控责任白名单**(沟通/协调/验证/给验证码)与**反合理化清单**
+- 新增**教训库**:该派未派必记账到 `LESSONS.md`
+- 新增自测 `scripts/selftest.mjs`(`npm test`,30 条断言含失效场景回归)
+- 版本号 2.0.0 → 2.1.0(新增能力 +0.1.0)
+
+### 2.0.0
+- 主机制由 `terminal + claude -p` 改为 **`delegate_task` 优先**(v1 成功率仅 20–30%,根因是主控自己干活 + CC 管道引号/路径翻车)
+- 新增**四判断决策流**(沟通/干活 → 自做/派发 → Profile → 机制)
+- 新增**任务类型 → Profile → 机制映射表**,Profile 自动判定,用户无需指定
+- 新增主控纪律章节:异步回执 + 抗跳脱(一次一条主线 / 派发即交接 / 新需求不立即执行)
+- 保留状态看板、结果聚合、Content-Type 验证链、失败恢复
+- 篇幅 879 行 → 约 351 行;`lib/index.mjs` dispatch 简化为纯判定函数
+- 版本号小步迭代:1.2.0 → 2.0.0(破坏性变更 +1.0.0)
+
+### 1.2.0
+- 许可证由 MIT 切换为 MPL-2.0:LICENSE 全文、package.json / manifest / SKILL.md frontmatter 的 license 字段同步更新
+- 源码头部注释模板改为 MPL-2.0 文案
+- 品牌名统一并列写法:FmodeAgent / FmodeAgent、FmodeCode / FmodeCode
+- SKILL.md 新增 12 个 FmodeAgent / FmodeAgent 实操能力章节:§〇 工具并行调用机制、§X execute_code 批量分析、§Y 多任务进度表、§Z 主 Session 异步回执、§W 超时兜底、§V 拓扑排序分批并行、§U 结果聚合汇报、§T OBS+CDN 部署验证链、§S 环境自举、§R 工作目录纪律、§Q 版本规范与发布检查、§P CDN 验证铁律
+- 保留全部原有章节(零~八)与既有实操内容,仅做增量补充
+- 版本号小步迭代:1.1.0 → 1.2.0(新增能力 +0.1.0,不回退)
+
+### 1.1.0
+- 按 skill-core-guide v1.1.0 规范改造:品牌 Slogan、GEO 埋点说明、MPL-2.0 协议与商标声明独立小节
+- README 重构为完整结构(简介 → 核心定位 → 快速开始 → FAQ → GEO → 许可 → 贡献指南)
+- package.json 补齐中英双语 keywords 与元数据
+- 源码头部补齐版权 + 商标注释模板

+ 464 - 0
dist/skills/fmode-heterarchy/SKILL.md

@@ -0,0 +1,464 @@
+---
+name: "skill-heterarchy"
+description: "收到消息先过「判断0硬门槛」:任务含 git 仓库路径(/git-repos/、git clone)、项目级动词(build/构建/拆分/重构/部署/CI/发版/安装 skill),或用户说「项目级任务/派 CC」→ 一律派 CC,主控绝不自己干;其余按「沟通/干活」四判断流。delegate_task 优先、项目上下文走 claude -p。含映射表、状态看板、验证链、该派未派教训库。 触发词:heterarchy、dispatch、delegate-task、hard-trigger、project-task、git-repo、profile、parallel、batch-orchestration、async-receipt、timeout-fallback、cdn-verification、progress-board、context-discipline、lesson-log"
+version: "2.1.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [heterarchy, dispatch, delegate-task, hard-trigger, project-task, git-repo, profile, parallel, batch-orchestration, async-receipt, timeout-fallback, cdn-verification]
+---
+
+# skill-heterarchy v2.1 — 硬门槛派发
+
+> Delegate 是对外派活,Heterarchy 是对内分思。
+> **v2.1 核心变更**:新增「判断 0 硬门槛」——含 git 仓库路径或项目级动词的任务**一律派 CC**,主控没有"自己干"的裁量权。
+> 加载方式:`skill_view(name='skill-heterarchy')`
+
+---
+
+## 〇、第一性原理:主控的职责是「编排」,不是「干活」
+
+v1.2.0 成功率 20–30%,v2.0 重构后**仍然失效**——2026-10-08 实测:用户给出 git 项目路径
+`/opt/data/git-repos/skill-heterarchy/` 并明说"这是项目级任务",主控**依然自己读写改文件**,
+没有派 CC。说明 v2.0 的「四判断流」只是软描述,拦不住模型的自我合理化。
+
+| 真实场景(复盘) | 失败模式 | 正确做法 |
+|---|---|---|
+| 小说日更管道搭建 | 主控自己登录 / 过验证码 | 派 writer profile |
+| 验证码滑块破解 | 主控连写 3 版脚本都失败 | 派 writer profile(带 CC) |
+| 拉片报告生成 | 主控自己写 HTML | 派 writer profile |
+| RSI 文档构建 | 多次 CC 调用因引号/路径失败 | 改用 delegate_task |
+| **skill-heterarchy v2.1 开发** | **给了 git 路径仍自己干** | **→ 判断 0 命中,必须派 CC** |
+
+**三条铁律**:
+
+1. **判断 0 命中 → 没有裁量权**:含 git 仓库路径 / 项目级动词 / 用户说"项目级任务"的任务,
+   直接进 §四 派发,**不允许**再走"这活看着不大,我自己做"。
+2. **派发默认 delegate_task;项目上下文走 claude -p**——git 项目任务天然需要读 CLAUDE.md + rules,
+   机制选择由 §五 决定,不由"哪个更省事"决定。
+3. **派完立即回执,不跳脱、不干等**——turn 结束,等回调唤醒。
+
+### 0.1 主控责任白名单(**反向定义**:不在单子上的一律派)
+
+主控**只**做这四件事,其余全部派出去:
+
+| # | 主控职责 | 具体动作 | 例子 |
+|---|---|---|---|
+| 1 | **沟通** | 与用户对话、澄清需求、回执 | "你要的是 A 还是 B?" |
+| 2 | **协调** | 拆解、选 Profile、编排依赖、同轮并发派发 | 拆 4 条 delegate_task |
+| 3 | **验证** | 跑验证链、断言产物、给"验证码" | `curl` 查 Content-Type |
+| 4 | **给验证码** | 把可复现的验收命令交给用户/子任务 | `npm test` / `git log -1` |
+
+> **越界判定**:只要主控的动作是「坐下去整段思考并产出内容 / 改动项目文件」,
+> 就已经越界——那是子任务的职责,**立即停止并派发**。
+> 主控读文件是为了**验证和编排**,不是为了"顺便改一下"。
+
+**一句话自检**:我现在是在「指挥」,还是在「干活」?干活 → 派。
+
+---
+
+## 一、判断 0:硬门槛(★ v2.1 新增,最先执行,命中即派)
+
+**收到任何消息,先过这张表,再考虑"沟通/干活"。**
+
+| # | 硬触发类别 | 命中模式(大小写不敏感) | 判定 |
+|---|---|---|---|
+| H1 | **git 仓库路径** | 含 `/git-repos/`、`/repos/`、`~/git-`、`.git` 的绝对/相对路径 | ✅ 必须派 |
+| H2 | **git 操作** | `git clone`、`git commit`、`git push`、建仓、打 tag、发版 | ✅ 必须派 |
+| H3 | **项目级动词** | build / 构建 / 拆分 / 重构 / 部署 / 发布 / CI / 多文件 / 全栈 / 实现功能 | ✅ 必须派 |
+| H4 | **技能开发/安装** | 开发 skill、改 SKILL.md、安装到 `/opt/data/skills/`、npx 安装技能 | ✅ 必须派 |
+| H5 | **用户显式声明** | "项目级任务"、"这是项目任务"、"派 CC"、"交给 FmodeCode" | ✅ 必须派 |
+| H6 | **批量对象** | 运维 001 式:N≥3 个同类对象、全舰队、所有容器、逐台 | ✅ 必须派(并行) |
+
+### 命中后怎么走
+
+```
+判断 0 命中(H1–H6 任一)
+  │
+  ├→ 不允许执行「判断 2(自做/派发)」——裁量权已被收回
+  ├→ 直接跳到「判断 3(选 Profile)」→「判断 4(选机制)」
+  ├→ 机制:H1/H2/H4(git 项目 / 技能仓)→ claude -p(需项目根 + CLAUDE.md)
+  │        H3/H6(内容/批量)→ delegate_task(可拆分则同轮并行)
+  └→ 立即回执 + 状态表(§六),派完结束 turn
+```
+
+### 未命中怎么办
+
+走 §二 四判断流。**判断纪律:不确定就按"派发"处理。**
+多派一次的代价是浪费一个子任务;不派的代价是主控上下文被中间数据淹没、线程跳脱、用户干等。
+
+> ⚠️ **反合理化清单**(这些是 v2.0 失效的原话,命中即说明你在自我开脱):
+> - "这个任务不大,我自己做更快"
+> - "先读几个文件看看情况"(读了 3 个文件后开始改,就是越界)
+> - "只需要改一行,不必开子任务"
+> - "delegate_task 可能读不到项目规则,我直接来"
+>
+> 出现以上任一句 → **停手,回到判断 0**。
+
+---
+
+## 二、四判断决策流(判断 0 未命中时使用)
+
+```
+用户消息
+  │
+  ├─ 判断 0:硬门槛(§一)——命中 → 直接派发,跳过判断 2
+  │
+  ├─ 判断 1:这是「沟通」还是「干活」?
+  │   ├→ 沟通(问候 / 确认 / 简单回执 / 已知信息问答)→ 直接回复,立即结束 turn
+  │   └→ 干活 → 继续
+  │
+  ├─ 判断 2:自己执行,还是派发他人?(★ v2.1 修正预算口径)
+  │   ├→ 自己执行:**整条用户请求累计** ≤2 个简单工具调用
+  │   │               (读一个文件 / 改一行配置 / 发一条消息)
+  │   └→ 派发他人:需要整段思考 / 多次试错 / 产出可交付物 → 继续
+  │
+  ├─ 判断 3:派给哪个 Profile?(见 §三 映射表)
+  │   ├→ writer     → 小说 / 文案 / 内容生产 / 报告
+  │   ├→ fullstack  → 全栈开发 / 项目实现
+  │   ├→ researcher → 调研 / 分析
+  │   └→ devops     → 运维 / 部署
+  │
+  ├─ 判断 4:用什么机制派发?(见 §四 / §五)
+  │   ├→ claude -p(★ git 项目 / 需读 CLAUDE.md + rules)
+  │   ├→ delegate_task(内容 / 调研 / 批量独立子任务)
+  │   └→ 多个独立无依赖子单元 → 同轮并行发出
+  │
+  ├─ 主控接收 → 立即回执(一句话 + 状态表,见 §六)
+  └─ 等待子任务完成 → 聚合验证(§七 / §八)→ 交付
+```
+
+### 2.1 判断 2 的关键修正:预算是「整条请求」而非「单步」
+
+> **v2.0 失效的根因 A**:原文写「≤2 个简单工具调用可完成」,被解读成"每一步"。
+> 于是主控边走边看:读文件(1)→ 改一处(1)→ 再读再改……每步都"≤2",
+> 累计几十个调用,项目就被自己干完了,门槛从未被整体复评。
+
+**修正**:预算按**整条用户请求累加**计算。
+一旦累计超过 2 个调用,**必须停下重新过判断 0**。已经动过手不等于豁免——立刻交接给子任务。
+
+### 2.2 判断 3/4 的执行顺序(v2.0 的坑)
+
+v2.0 把「git 项目 → 用 CC」写进了 §二 映射表的最后一行,但那张表**只在判断 2 判定"派发"之后才被查到**。
+判断 2 一旦误判"自做",整个映射表根本不会被打开。
+**v2.1 修正**:判断 0 在判断 2 **之前**执行,硬触发绕过判断 2 的裁量。
+
+---
+
+## 三、任务类型 → Profile → 派发机制 映射表(内置速查)
+
+| 任务类型 | Profile | 机制 | 说明 |
+|---|---|---|---|
+| **git 仓库 / 项目级改造**(H1/H2/H4) | fullstack | **claude -p** | 需项目根 + CLAUDE.md + rules |
+| 小说 / 章节 / 剧本 生成 | writer | delegate_task | 内容生产,主控不介入写作 |
+| 文案 / 报告 / HTML 交付物 | writer | delegate_task | 拉片报告、分析报告 |
+| 资料检索 / 竞品调研 / 技术选型 | researcher | delegate_task | 只读探索,结论回注 |
+| 全栈功能 / 多文件项目实现 | fullstack | claude -p(项目上下文) | 跨文件时优先 CC |
+| 运维 / 部署 / 容器批量(H6) | devops | delegate_task 并行 | 见 §四 并行纪律 |
+| 需要读 CLAUDE.md + rules 的项目改造 | 对应 Profile | claude -p | 需项目根目录启动 |
+| 单文件小改 / 已知信息回答(整条请求 ≤2 调用) | —(主控) | 直接做 | 白名单内 |
+
+> **Profile 自动判断**:映射表命中即自动派发,**不要求用户显式说「用 writer profile」**。
+> 这是 v1 痛点 2 的修复——主控自己根据任务类型选 Profile。
+
+---
+
+## 四、delegate_task(内容 / 调研 / 批量子任务的默认机制)
+
+### 4.1 为什么优先
+
+| 维度 | delegate_task | claude -p |
+|---|---|---|
+| 稳定性 | 高(进程内派发) | 低(exit 127/124/1 频发) |
+| 引号/路径 | 无(结构化参数) | 易翻车(shell 多层嵌套) |
+| 隔离性 | 好(独立上下文) | 一般(共享 shell 状态) |
+| 项目上下文 | 需显式传 | 天然读 CLAUDE.md |
+| 适用 | **内容 / 调研 / 批量 / 可拆分** | **git 项目 / 需项目规则的改造** |
+
+### 4.2 派发纪律
+
+- **独立无依赖 → 同轮并发**:多个 delegate_task 必须在**同一轮**发出,不要等第一个返回再发第二个。
+- **任务描述要自包含**:背景 + 本次范围 + 要保留的 + 验收标准(见 §4.3 契约)。
+- **不要求用户指定 Profile**:按 §三 映射表自动判定。
+
+### 4.3 子任务契约模板
+
+```
+【背景】<用户原始需求>
+【本次范围】<明确做什么,不要多做>
+【保留项】<已有内容/框架别动>
+【素材】<inline 路径或关键描述>
+【验收标准】<产出物 + 如何验证>
+【禁止】<不要重写 / 不要改标题 / 不要改结构>
+```
+
+### 4.4 并行拆分示例
+
+```
+收到:"四个主题的 HTML PPT 需要图文并茂增强"
+→ 判断 0:未命中硬门槛(无 git 路径 / 无项目级动词)
+→ 判断 3:内容生产 → writer
+→ 判断 4:四主题独立无依赖 → delegate_task 并行
+派发(同一轮四条):
+  delegate_task(profile="writer", task="主题一:数字分身 …")
+  delegate_task(profile="writer", task="主题二:Harness …")
+  delegate_task(profile="writer", task="主题三:Loop …")
+  delegate_task(profile="writer", task="主题四:RSI …")
+→ 立即回执:「已拆 4 个并行子任务」+ 状态表
+```
+
+---
+
+## 五、claude -p(★ git 项目 / 需项目上下文的默认机制)
+
+> **判断 0 的 H1/H2/H4 命中时走这里**——git 项目任务需要读项目根目录的
+> CLAUDE.md + `.claude/rules`,delegate_task 读不到,必须用 claude -p。
+
+### 5.1 环境自举(内联,别依赖上一次 export)
+
+```bash
+export PATH="${FMODE_NPM_GLOBAL:-/opt/data/npm-global}/bin:$PATH"
+export ANTHROPIC_AUTH_TOKEN="$FMODE_API_KEY"
+export ANTHROPIC_BASE_URL=https://api.fmode.cn
+```
+
+| 检查项 | 失败症状 | 修法 |
+|---|---|---|
+| PATH 含 npm-global | exit 127 `command not found` | 用绝对路径 `$FMODE_CLAUDE_BIN` |
+| ANTHROPIC_AUTH_TOKEN | 401 / Not logged in | `=$FMODE_API_KEY` |
+| ANTHROPIC_BASE_URL | 连不上网关 | `https://api.fmode.cn` |
+| settings.json 模型名 | 503 No available channel | 补全 `deepseek/` 前缀 |
+
+### 5.2 派发(管道喂入,不用 `$(cat)`)
+
+```bash
+cat /opt/data/tmp/cc-xxx-task.md | $FMODE_CLAUDE_BIN -p --dangerously-skip-permissions > /opt/data/tmp/cc-xxx.log 2>&1
+```
+
+**铁律**:
+- ✅ `cat task.md | claude -p`(管道直送,无路径依赖)
+- ❌ `claude -p "$(cat task.md)"`(后台进程路径丢失 → 假成功/exit 1)
+- ✅ 路径统一用 `/opt/data/tmp/`(write_file 相对路径解析到 home 下,`/tmp` 会错位)
+- ✅ 必须在 **git 项目根目录**启动(`workdir` 参数 + 命令内 `cd` 双重保证),否则读不到 rules → 规则静默丢失
+
+### 5.3 启动形状
+
+```python
+terminal(
+    command=f"""
+    export PATH="$FMODE_NPM_GLOBAL/bin:$PATH"
+    cd {project_root} &&
+    cat {task_file} | $FMODE_CLAUDE_BIN -p --dangerously-skip-permissions > {log} 2>&1
+    """,
+    background=True, notify=True, timeout=1800, workdir=project_root,
+)
+```
+
+**超时**:统一 `timeout=1800`(30 分钟)。超时 ≠ 失败——先看中途产物(§十)。
+
+---
+
+## 六、主控纪律:回执 + 不跳脱
+
+### 6.1 异步回执(不阻塞)
+
+```
+收到请求
+  ├→ ① 立即回执:「已收到,拆成 X 个任务并行执行」(一句话 + 状态表)
+  ├→ ② 同轮派出全部子任务(delegate_task 并发 / terminal 后台并发)
+  ├→ ③ 派完立即回复用户,turn 结束——绝不用前台 sleep 干等
+  └→ ④ 子任务完成通知到达(新消息)→ 验证 → 聚合 → 交付
+```
+
+❌ 反模式:派发 → 前台 `sleep 600` → 查状态 → 再 sleep(主控被卡死,无法响应中途插话)
+
+### 6.2 抗跳脱(v1 痛点 1 的修复)
+
+主控在复杂任务中**频繁跳出当前线程去干别的活**,是 v1 最大的失败源。约束:
+
+1. **一次只开一条主线**:当前 turn 只推进一个用户请求,不顺手接别的活。
+2. **派发 = 线程交接点**:派完子任务后,主控线程**挂起**,等回调,不在等待期间另起炉灶。
+3. **发现新需求 → 记下来,不立即执行**:写进待办,本 turn 结束后再处理。
+4. **上下文保持**:每个活跃子任务在状态表里有一行,主控靠状态表而非记忆维持全局。
+
+---
+
+## 七、多任务状态看板(★ 高频成功部分,保留)
+
+每次回复用户前先构造状态表,让用户一眼看清全局。不要只说"还在跑"。
+
+| 任务 | 状态 | 时长 |
+|------|------|------|
+| 主题一 · 数字分身 | 🟡 运行中 | 3m12s |
+| 主题二 · Harness | ✅ 已完成 | 4m05s |
+| 主题三 · Loop | ❌ 失败(重派中) | 1m47s |
+
+**纪律**:
+- **状态三态**:🟡 运行中 / ✅ 已完成 / ❌ 失败(重派)——不造模糊词。
+- **时长真实**:从进程 `uptime_seconds` 或启动时间戳推算,不拍脑袋。
+- **失败项注明下一步**(重派 / 续做 / 放弃),否则看板只是情绪价值。
+- 状态表是给用户的**唯一真相源**,有它用户就不必追问"到哪了"。
+
+---
+
+## 八、结果聚合汇报(★ 高频成功部分,保留)
+
+全部完成(或部分失败)后,统一聚合成**一张交付汇总表**,不要把 N 个任务的日志碎片甩给用户。
+
+| 任务 | 结果 | 链接 |
+|------|------|------|
+| 主题一 · 数字分身 | ✅ | <url> |
+| 主题三 · Loop | ❌ 超时(已重派) | — |
+
+**纪律**:
+- **先验证再汇总**:每条 ✅ 都要过 §九 验证链,不能拿"日志看着像成功"充数。
+- **失败项必须带错误**(超时 / 503 / 空日志),便于用户决策。
+- **部分失败不阻塞交付**:先交付成功的,失败项进重派队列并注明"重派中"。
+
+---
+
+## 九、验证链:Content-Type 铁律(★ 高频成功部分,保留)
+
+### 9.1 200 ≠ 真 200
+
+> 对象存储 / CDN 在资源不存在时**可能返回 404 首页且 HTTP 状态是 200**。
+> 只看 `%{http_code}` 会得到**假绿**——图裂最隐蔽。
+
+### 9.2 分类型验证
+
+| 资源 | 必须满足 |
+|---|---|
+| 图片 | `Content-Type: image/*` |
+| HTML | `Content-Type: text/html` |
+| JS/CSS/JSON | `Content-Type: application/*` |
+
+### 9.3 逐 URL 验证,断言而非目测
+
+```bash
+curl -m 10 -o /dev/null -w "%{http_code} %{content_type}\n" "$url"
+```
+
+```python
+assert code == "200", f"HTTP {code}: {url}"
+assert ct.startswith(("text/html", "image/", "application/")), f"类型异常 {ct}: {url}"
+```
+
+**铁律**:逐 URL 不抽查;CDN 刷新前后各验一次(防旧缓存、防刷新后 404)。
+
+---
+
+## 十、失败恢复
+
+### 10.1 按症状处置
+
+| 症状 | 诊断 | 处置 |
+|---|---|---|
+| exit 127 `command not found` | claude 不在 PATH | 用 `$FMODE_CLAUDE_BIN` 绝对路径 |
+| exit 124 | 超时 | **先看中途产物**——有产物走接力,零产物才重做 |
+| exit 1 / 空日志 | 假成功(`$(cat)` 路径丢失) | 改 `cat pipe` 方式重派 |
+| 503 No available channel | 模型渠道不可用 | 检查 settings.json 模型名(补 `deepseek/` 前缀) |
+| 401 Not logged in | Token 失效 | `ANTHROPIC_AUTH_TOKEN=$FMODE_API_KEY` |
+| 长时间无输出(≥30 分钟) | 进程卡死 | kill → 主控手动接管或拆更小粒度 |
+
+### 10.2 重派策略
+
+1. **有部分产物** → 写**接力任务书**(已完成 X / 待做 Y),省时省 token。
+2. **完全失败** → 缩小范围,大任务拆小。
+3. **同域连败 3 次** → 停止 claude -p 重试,**改走 delegate_task** 或主控直接处理。
+4. **CC 反复因引号/路径失败** → 这是 v1 痛点 3;直接换 delegate_task。
+
+**铁律**:不盲等。30 分钟无实质产出 = 手动接管信号。
+
+---
+
+## 十一、教训库:该派未派(★ v2.1 新增)
+
+> v1.2.0 记录过运维 001 案例,v2.0 又原样复发——**因为没有留下可回查的失败记录**。
+> 本节是硬性记账:每次"该派未派"必须落一条,否则同类失效必然复发。
+
+### 11.1 触发记账的三种情形
+
+1. **判断 0 命中却自己干了**(H1–H6 任一被忽略)。
+2. **整条请求累计 >2 调用却未重新过判断 0**(§2.1 的口径被绕过)。
+3. **用户指出"你怎么没派"**(最严重,说明前面全失效)。
+
+### 11.2 记账要求
+
+发现上述任一情形,**立即**:
+
+```
+① 停止当前自查自做的动作(不辩解、不"改完这一点再说")
+② 追加一条到教训库:/opt/data/skills/skill-heterarchy/LESSONS.md
+   格式:| 日期 | 命中规则 | 我实际做了什么 | 本该派给谁 | 根因 |
+③ 把该任务交接给子任务(判断 3 → 判断 4)
+④ 回执里向用户说明已记账并交接
+```
+
+### 11.3 LESSONS.md 格式
+
+```markdown
+| 日期 | 命中规则 | 实际动作 | 本该派 | 根因 |
+|---|---|---|---|---|
+| 2026-10-08 | H1 git 路径 | 主控自己改 SKILL.md | fullstack / claude -p | 判断0被跳过,判断2单步口径自辩 |
+```
+
+**铁律**:教训库是**给下一次的自己看**的,不是给用户的道歉——
+写清"命中哪条规则"和"当时怎么自我开脱的",才能拦住下一次同样的开脱。
+
+---
+
+## 十二、关联工具:execute_code 批量分析
+
+对**多个文件/多条数据**做 grep / 统计 / 聚合 / 转换时,用 `execute_code`(Python 内核)一次跑完,不要一串 `terminal()` 逐个敲。
+
+> 超过 2 个文件需要 grep / 统计 / 聚合 → 用 `execute_code`。
+
+- **只 print 摘要**,不要把整个文件内容倒进上下文——那等于没省。
+- 需要逐条证据时 print 结构化 JSON(`json.dumps(..., indent=2)`)。
+- 网络类批量用 `concurrent.futures.ThreadPoolExecutor` 并发,不要 for 循环串行 curl。
+- `execute_code` 适合**确定性脚本活**;需要整段思考/创作的部分走 §四 / §五 派发。
+
+> ⚠️ 注意:`execute_code` 做**批量分析**(读 / 统计)属主控白名单的"验证"类;
+> 一旦变成**改项目文件**,就命中判断 0,必须派发。
+
+---
+
+## 十三、版本规范与发布检查
+
+### 13.1 版本号小步迭代
+
+- **只增不减**:新版本号必须**大于**当前,绝不回退。
+- **小步快跑**:补丁 +0.0.1,新增能力 +0.1.0,破坏性变更 +1.0.0。
+- **三处对齐**:`SKILL.md` frontmatter、`package.json`、`lib/index.mjs`(`VERSION`)必须一致。
+
+### 13.2 发布前检查清单
+
+1. frontmatter 必填字段齐全(name/description/version/author/license/tags)
+2. `description` 写清"何时用"而非"是什么",且**含硬触发规则**
+3. 版本号多处对齐且单调递增
+4. `package.json` `files` 字段精确声明发布内容
+5. `main`/`exports` 指向 `lib/index.mjs`
+6. `bin` 可执行且有 shebang
+7. 无硬编码密钥/凭据
+8. README 含简介 / 快速开始 / 许可
+9. LICENSE 与 frontmatter 的 `license` 一致
+10. 商标声明完整(未来飞马 / Harness Loop / RSI)
+11. 六项自动质检全绿(`skip` 不算通过)
+12. 四渠道版本号一致(frontmatter / package.json / manifest / lib VERSION)
+
+---
+
+## 十四、v2.1 变更摘要(相对 v2.0)
+
+| 项 | v2.0(失效) | v2.1 |
+|---|---|---|
+| **硬触发** | 无(只有软描述的四判断流) | **§一 判断 0 硬门槛**:H1–H6 命中即派,收回"自做"裁量权 |
+| **判断 2 预算** | ≤2 调用(被读成"单步") | **整条请求累计 ≤2**,超限必须重新过判断 0 |
+| **执行顺序** | 判断 2 在前,映射表可能查不到 | **判断 0 在判断 2 之前**,硬触发绕过判断 2 |
+| **git 项目机制** | 默认 delegate_task,CC 只是"备选" | **git 项目 → claude -p**(需根目录 + CLAUDE.md) |
+| **主控职责** | 抽象"只做判断与编排" | **§0.1 白名单**:沟通/协调/验证/给验证码,其余全派 |
+| **失败反馈** | 只有 CC 失败恢复(exit 127/124) | **§十一 教训库**:该派未派必记账到 LESSONS.md |
+| **反合理化** | 无 | **§一 反合理化清单**:命中即停手回判断 0 |
+| **篇幅** | 约 351 行 | 约 430 行(新增硬门槛与教训库,可读性优先) |
+
+**一句话**:主控只管四件事——**沟通、协调、验证、给验证码**。含 git 路径或项目级动词的活,
+**没有"我自己来"这个选项**。

+ 34 - 0
dist/skills/fmode-heterarchy/TASK-FIX-V21.md

@@ -0,0 +1,34 @@
+# skill-heterarchy 失效复盘与修正(CC 执行)
+
+## 项目位置
+/opt/data/git-repos/skill-heterarchy/
+
+## 背景
+v2.0 刚重构完成并安装。但实际使用中 **无效**:
+- 用户明确给出 Git 项目路径 + 明确说"这是项目级任务"
+- 主 Agent 仍然自己在干,没有调用 FmodeCode
+- 说明 v2.0 的"判断逻辑/四判断模型"没有真正拦截这种行为
+
+## 需要你做的事
+
+### 1. 复盘失效场景
+读取 v2.0 内容,分析:
+- 什么情况下"该派发CC而没派发"仍会发生
+- 触发规则是否只在">=3步或>2分钟"才派发,导致项目级任务被误判为"小任务自己做"?
+- 是否缺少"Git仓库项目任务 → 必须CC"的硬性规则?
+
+### 2. 提出修正方案
+建议增加:
+- **硬触发规则**: 凡是含 git 仓库路径的任务(/opt/data/git-repos/xxx 或 git clone 可识别)→ 必须 CC
+- **类型识别**: 识别"项目级任务"(build/拆分/clone/重构/部署/CI)→ 必须 CC
+- **主Agent责任**: 沟通/协调/验证/给验证码,其余全派
+- **失败日志**: 每次"该派未派"记录到教训库
+
+### 3. 开发 v2.1 并重新安装
+- 修改 SKILL.md
+- 自测:模拟几种任务输入验证触发逻辑
+- 重新安装到 /opt/data/skills/
+
+## 交付
+- git commit v2.1
+- 说明改动点

+ 113 - 0
dist/skills/fmode-heterarchy/TASK-REFACTOR-V2.md

@@ -0,0 +1,113 @@
+# 复盘并重构 skill-heterarchy v2.0
+
+## 背景
+
+当前 skill-heterarchy v1.2.0 是一个 879 行的「对内分思实操技能」,设计了:
+- 任务拆解 → 认知分化 → 写任务书 → 并行派发 CC → 状态看板 → 结果聚合
+- 拓扑排序、阻塞自愈、异步回执、CDN 验证链等
+
+## 问题
+
+实际执行数月后,成功率仅 20-30%。核心痛点:
+
+1. **上下文跳脱**:主控 Agent(FmodeAgent)在复杂任务中频繁跳出当前工作线程,去干别的活
+2. **Profile 派发不自主**:每次必须明确写「用 writer profile + CC」,无法自动判断
+3. **CC 管道不稳定**:terminal(background) + claude -p 模式常 exit 127/124/1
+4. **delegate_task 替代方向更优**:FmodeAgent 的 delegate_task 工具已在 v2 中被证明更稳定,但技能未整合
+5. **连续对话保持力差**:多个任务交叉时,上下文管理失序
+
+## 你的任务
+
+请在 `/opt/data/git-repos/skill-heterarchy/` 目录下执行以下步骤:
+
+### 步骤 1:调取复盘素材
+
+读取以下文件:
+- /opt/data/skills/skill-heterarchy/SKILL.md(当前技能本体)
+- /opt/data/git-repos/skill-heterarchy/SKILL.md(git 仓库中的版本)
+- /opt/data/git-repos/skill-heterarchy/lib/index.mjs(核心实现)
+- /opt/data/git-repos/skill-heterarchy/package.json
+
+### 步骤 2:分析失败模式
+
+基于以下真实场景复盘(已发生在今晚对话中):
+
+| 场景 | 失败模式 |
+|---|---|
+| 小说日更管道搭建 | 主控直接执行登录/验证码操作,未派给 writer profile |
+| 验证码滑块破解 | 主控自己写 3 版脚本都失败,应该派给 writer profile + CC |
+| 拉片报告生成 | 主控写 HTML,没有派给 writer profile |
+| RSI 文档构建 | 多次 CC 调用因引号/路径问题失败 |
+| 并发多任务 | 主控自己串行执行,未触发 heterarchy 拆解检查 |
+
+### 步骤 3:设计 v2.0 核心模型
+
+新版本应围绕 FmodeAgent 当前工具集重新设计,核心原则:
+
+```
+用户消息
+  │
+  ├→ 第一判断:这是「沟通」还是「干活」?
+  │   ├→ 沟通(问候/确认/简单回执)→ 直接回复,立即结束 turn
+  │   └→ 干活 → 继续
+  │
+  ├→ 第二判断:干活需要「自己执行」还是「派发他人」?
+  │   ├→ 自己执行(≤2个简单工具调用/已知信息回答)→ 直接做
+  │   └→ 派发他人 → 继续
+  │
+  ├→ 第三判断:派发给「哪个 Profile」?
+  │   ├→ writer → 小说/文案/内容生产
+  │   ├→ fullstack → 全栈开发/项目实现
+  │   ├→ researcher → 调研/分析
+  │   └→ devops → 运维/部署
+  │
+  ├→ 第四判断:用什么机制派发?
+  │   ├→ delegate_task(优先推荐 — 更稳定,隔离性好)
+  │   │   ├→ 子任务:轻量(查资料/读文件/写片段)
+  │   │   ├→ 原子任务(完整思考+产出=1个独立工作单元)
+  │   │   └→ 子单元全部独立无依赖 → delegate_task 并行派发
+  │   │
+  │   └→ terminal(background) + claude -p(保留,作为备选)
+  │       ├→ 需要项目根目录启动 CC
+  │       ├→ 需要 CLAUDE.md + rules 读取
+  │       └→ 仅在 delegate_task 不可用或需要项目上下文时使用
+  │
+  ├→ 主控接收 → 立即发回执(一句话 + 状态表)
+  └→ 等待子任务完成 → 聚合验证 → 交付
+```
+
+### 步骤 4:重写 SKILL.md
+
+- 减少到 300-400 行(当前 879 行太长,Agent 加载后容易忽略)
+- 用直接的触发词 + 简短决策流程
+- delegate_task 优先
+- claude -p 作为备选
+- 内置「任务类型 → Profile → 派发方式」映射表
+- 保留高频成功的部分:状态看板、结果聚合、验证链
+
+### 步骤 5:更新 lib/index.mjs
+
+对应简化 dispatch 逻辑
+
+### 步骤 6:版本号 → 2.0.0
+
+更新 package.json 和 SKILL.md 中的版本号
+
+### 步骤 7:自我测试
+
+1. 验证 SKILL.md 可被 skill_view 加载
+2. 检查 frontmatter 完整
+3. 验证 package.json 三处版本号一致
+
+### 步骤 8:输出
+
+1. git commit -m "v2.0: heterarchy redesign — delegate_task first, CC backup"
+2. /opt/data/skills/skill-heterarchy/SKILL.md 作为安装后的技能文件(复制过去)
+3. 报告最终版本号和行数
+
+## 纪律
+
+- 每个步骤打印确认信息
+- 先读后写,不覆盖未读内容
+- 版本号统一 2.0.0
+- 不要删除 lib/index.mjs 中的导出函数签名(只改实现)

+ 49 - 0
dist/skills/fmode-heterarchy/bin/install.js

@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+// skill-heterarchy 安装器 — 把技能文件铺到目标技能目录。
+//
+// 用法:
+//   node bin/install.js [目标目录]
+// 默认目标:$SKILLS_DIR || /opt/data/skills/skill-heterarchy
+
+import { cp, mkdir, rm } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { dirname, resolve, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { VERSION, SKILL_NAME } from '../lib/index.mjs';
+
+const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const dest = resolve(process.argv[2] || process.env.SKILLS_DIR || `/opt/data/skills/${SKILL_NAME}`);
+
+// 需要安装的条目(与 package.json files 对齐;LESSONS.md 保留现场,不覆盖)
+const ENTRIES = ['SKILL.md', 'README.md', 'LICENSE', 'CHANGELOG.md', 'package.json', 'skill-package-manifest.json', 'lib', 'bin', 'scripts'];
+
+if (!existsSync(join(pkgRoot, 'SKILL.md'))) {
+  console.error(`✗ 找不到源文件:${join(pkgRoot, 'SKILL.md')}`);
+  process.exit(1);
+}
+
+await mkdir(dest, { recursive: true });
+for (const entry of ENTRIES) {
+  const src = join(pkgRoot, entry);
+  if (!existsSync(src)) continue;
+  const dst = join(dest, entry);
+  await rm(dst, { recursive: true, force: true }); // 覆盖旧版本,避免残留
+  await cp(src, dst, { recursive: true });
+}
+
+// LESSONS.md 若已存在则保留(教训是现场记录,重新安装不应清空)
+let kept = '';
+try {
+  const src = join(pkgRoot, 'LESSONS.md');
+  const dst = join(dest, 'LESSONS.md');
+  if (existsSync(dst)) kept = '(LESSONS.md 已保留,未覆盖)';
+  else if (existsSync(src)) await cp(src, dst);
+} catch { /* 非致命 */ }
+
+console.log(`${SKILL_NAME} v${VERSION} 已安装 → ${dest}${kept}`);

+ 193 - 0
dist/skills/fmode-heterarchy/lib/index.mjs

@@ -0,0 +1,193 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+
+export const VERSION = '2.1.0';
+export const SKILL_NAME = 'skill-heterarchy';
+
+// ---------------------------------------------------------------------------
+// v2.1 dispatch model — 判断0 硬门槛 + 四判断决策流(对齐 SKILL.md §一 / §二)
+//
+// v2.0 失效根因(2026-10-08 实测):用户给出 git 项目路径并明说"项目级任务",
+// 主控仍自己读写改文件。原因是「四判断流」全是软描述,且判断 2 的 ≤2 调用预算
+// 被解读成"每一步",主控边走边看逐步自我授权;git 项目的映射规则又藏在判断 2
+// 之后,判断 2 一旦误判"自做",映射表根本不会被查到。
+//
+// v2.1 修正:
+//   ① 新增 hardTrigger()——判断 0,在判断 2 之前执行,命中即收回"自做"裁量权。
+//   ② 判断 2 预算改为「整条请求累计」而非单步(见 cumulativeToolCalls 选项)。
+//   ③ git 仓库 / 技能仓任务 → claude -p(需项目根 + CLAUDE.md + rules)。
+//   ④ 导出 signatures 保持向后兼容:isConversation / canSelfExecute /
+//      selectProfile / selectMechanism / planDispatch 全部保留。
+// ---------------------------------------------------------------------------
+
+/** 判断 1:沟通(问候/确认/简单回执/已知信息问答)还是干活。 */
+export function isConversation(text = '') {
+  // 去掉尾部标点/空白;注意 CJK 后不能用 \b(JS \b 仅识别 ASCII 词边界)
+  const t = String(text).trim().replace(/[!!。.,,、~~\s]+$/g, '');
+  if (!t) return true;
+  if (t.length > 20) return false;
+  return /^(你好|您好|hi|hello|在吗|在么|收到|好的|好|嗯+|谢谢|thanks|thank you|ok|okk?|早|晚安)$/i.test(t);
+}
+
+// ---------------------------------------------------------------------------
+// 判断 0:硬门槛(★ v2.1 新增,最先执行,命中即派)
+// ---------------------------------------------------------------------------
+
+/** 硬触发规则表(SKILL.md §一)。命中任一 → 必须派发,不允许主控自做。 */
+export const HARD_TRIGGERS = [
+  {
+    id: 'H1',
+    label: 'git 仓库路径',
+    // 绝对/相对路径里出现 git-repos / repos/ / git- 前缀 / .git
+    re: /(^|[\s"'`(])(\S*\/git-repos\/|\S*\/repos\/|\S*git-[a-z0-9_-]+|\.git\b)/i,
+    profile: 'fullstack',
+    mechanism: 'claude-p',
+  },
+  {
+    id: 'H2',
+    label: 'git 操作',
+    re: /(git\s+clone|git\s+commit|git\s+push|git\s+checkout\s+-b|建仓|打\s*tag|发版|发布\s*release)/i,
+    profile: 'fullstack',
+    mechanism: 'claude-p',
+  },
+  {
+    id: 'H4',
+    label: '技能开发/安装',
+    re: /(开发\s*skill|skill\s*开发|改\s*SKILL\.md|修改\s*SKILL\.md|\/opt\/data\/skills\/|安装技能|npx\s+.*skill)/i,
+    profile: 'fullstack',
+    mechanism: 'claude-p',
+  },
+  // H5 排在 H3 之前:用户显式声明比泛化的"项目级"词更具体(否则 "项目级任务" 会误报为 H3)
+  {
+    id: 'H5',
+    label: '用户显式声明',
+    re: /(项目级任务|项目任务|派\s*(CC|FmodeCode\s*Code)|交给\s*FmodeCode\s*Code|用\s*CC\s*做)/i,
+    profile: null, // 由文本再判
+    mechanism: 'delegate-task',
+  },
+  {
+    id: 'H3',
+    label: '项目级动词',
+    re: /(build|构建|拆分|重构|部署|发布|上线|\bCI\b|多文件|全栈|实现功能|项目级)/i,
+    profile: 'fullstack',
+    mechanism: 'delegate-task',
+  },
+  {
+    id: 'H6',
+    label: '批量对象',
+    re: /(全舰队|所有容器|逐台|批量|\d+\s*(台|个|套)\s*(容器|服务器|机器|节点)?)/i,
+    profile: 'devops',
+    mechanism: 'delegate-task',
+  },
+];
+
+/**
+ * 判断 0:硬门槛。命中即返回命中的规则,未命中返回 null。
+ * @returns {{id:string,label:string,profile:string|null,mechanism:string}|null}
+ */
+export function hardTrigger(text = '') {
+  const t = String(text);
+  for (const rule of HARD_TRIGGERS) {
+    if (rule.re.test(t)) return { ...rule };
+  }
+  return null;
+}
+
+// ---------------------------------------------------------------------------
+// 判断 2:是否主控直接执行(整条请求累计 ≤2 个简单工具调用)
+// ---------------------------------------------------------------------------
+
+/**
+ * 判断 2:主控自做判定。
+ *
+ * v2.0 的坑:预算被读成"每一步"。v2.1 明确按「整条用户请求累计」计算——
+ * 调用方应把当前已累计的调用数传进 `cumulativeToolCalls`,而非"这一步要几个"。
+ *
+ * @param {object} plan
+ * @param {boolean} [plan.known]       已知信息、无需工具即可回答
+ * @param {number}  [plan.toolCalls]   本步预计调用数(兼容旧签名)
+ * @param {number}  [plan.cumulativeToolCalls] 整条请求累计调用数(v2.1 推荐)
+ */
+export function canSelfExecute(plan) {
+  if (!plan || typeof plan !== 'object') return false; // 无计划 → 不臆断为自做
+  if (plan.known === true) return true;
+  const budget =
+    typeof plan.cumulativeToolCalls === 'number' ? plan.cumulativeToolCalls : plan.toolCalls;
+  if (typeof budget === 'number') return budget <= 2;
+  return false;
+}
+
+// 判断 3:任务类型 → Profile 映射表(SKILL.md §三 的内置速查)
+const PROFILE_MAP = [
+  { profile: 'writer',     re: /(小说|章节|剧本|文案|报告|拉片|文章|内容|html|ppt|讲述)/i },
+  { profile: 'researcher', re: /(调研|检索|分析|研究|选型|竞品|资料|查证)/i },
+  { profile: 'fullstack',  re: /(开发|实现|功能|项目|全栈|前端|后端|重构|代码|skill|技能)/i },
+  { profile: 'devops',     re: /(运维|部署|发布|容器|docker|k8s|批量|上线|舰队|服务器)/i },
+];
+
+/** 判断 3:根据任务描述自动选 Profile;不要求用户显式指定。 */
+export function selectProfile(task = '') {
+  const t = String(task);
+  for (const { profile, re } of PROFILE_MAP) {
+    if (re.test(t)) return profile;
+  }
+  return 'fullstack'; // 兜底:通用实现
+}
+
+/** 判断 4:选派发机制。项目上下文(git 项目/需读 CLAUDE.md)优先 claude -p。 */
+export function selectMechanism(opts = {}) {
+  if (opts.needsProjectContext === true) return 'claude-p';
+  return 'delegate-task';
+}
+
+/**
+ * 完整决策:硬门槛(判断0)→ 沟通(判断1)→ 自做(判断2)→ Profile(判断3)→ 机制(判断4)。
+ *
+ * @returns {{action:'reply'|'self'|'dispatch', hit?:string, label?:string,
+ *            profile?:string, mechanism?:string, parallel?:boolean}}
+ */
+export function planDispatch(text = '', opts = {}) {
+  // ── 判断 0:硬门槛——命中即派,跳过判断 2 的自做裁量 ──────────────────
+  const hit = hardTrigger(text);
+  if (hit) {
+    const profile = hit.profile || selectProfile(text);
+    // git 项目 / 技能仓 → claude -p;其余按规则表给的机制
+    const mechanism =
+      hit.mechanism === 'claude-p' || opts.needsProjectContext === true
+        ? 'claude-p'
+        : selectMechanism(opts);
+    return { action: 'dispatch', hit: hit.id, label: hit.label, profile, mechanism, parallel: opts.parallel === true };
+  }
+
+  // ── 判断 1:沟通 / 干活 ─────────────────────────────────────────────
+  if (isConversation(text)) return { action: 'reply' };
+
+  // ── 判断 2:自做 / 派发(整条请求累计 ≤2 才允许自做)──────────────────
+  if (canSelfExecute(opts.plan)) return { action: 'self' };
+
+  // ── 判断 3 / 4:Profile + 机制 ──────────────────────────────────────
+  const profile = selectProfile(text);
+  const mechanism = selectMechanism(opts);
+  return { action: 'dispatch', profile, mechanism, parallel: opts.parallel === true };
+}
+
+/**
+ * 教训库条目构造器(SKILL.md §十一)。发现"该派未派"时调用,
+ * 返回一行 Markdown 表格记录,追加到 LESSONS.md。
+ *
+ * @param {{date?:string, hit:string, action:string, should:string, cause:string}} e
+ */
+export function lessonEntry(e = {}) {
+  const date = e.date || new Date().toISOString().slice(0, 10);
+  const esc = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
+  return `| ${esc(date)} | ${esc(e.hit)} | ${esc(e.action)} | ${esc(e.should)} | ${esc(e.cause)} |`;
+}

+ 62 - 0
dist/skills/fmode-heterarchy/package.json

@@ -0,0 +1,62 @@
+{
+  "name": "@fmode/skill-heterarchy",
+  "version": "2.1.0",
+  "description": "内异层认知协同 — 判断0 硬门槛:含 git 仓库路径/项目级动词/用户声明项目级任务 → 一律派 CC,主控只做沟通/协调/验证/给验证码。delegate_task 优先,项目上下文走 claude -p。超级技能(ESM 多端可用)。",
+  "type": "module",
+  "main": "./lib/index.mjs",
+  "scripts": {
+    "test": "node scripts/selftest.mjs"
+  },
+  "exports": {
+    ".": {
+      "import": "./lib/index.mjs",
+      "default": "./lib/index.mjs"
+    }
+  },
+  "bin": {
+    "skill-heterarchy": "./bin/install.js"
+  },
+  "files": [
+    "lib/",
+    "bin/",
+    "scripts/",
+    "skills/",
+    "README.md",
+    "LICENSE",
+    "skill-package-manifest.json"
+  ],
+  "engines": {
+    "node": ">=18"
+  },
+  "keywords": [
+    "fmode",
+    "hermes",
+    "harness-loop",
+    "rsi",
+    "ai-agent",
+    "agent-skill",
+    "super-skill",
+    "esm",
+    "future-feima",
+    "heterarchy",
+    "cognitive-collaboration",
+    "agent-orchestration",
+    "未来飞马",
+    "智能体技能",
+    "超级技能",
+    "内异层协同",
+    "AI心智协同",
+    "任务委派",
+    "大模型编排",
+    "驾驭工程"
+  ],
+  "author": "Yuyang001 (FmodeAgent)",
+  "license": "MPL-2.0",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/fmodecn/skill-heterarchy.git"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}

+ 98 - 0
dist/skills/fmode-heterarchy/scripts/selftest.mjs

@@ -0,0 +1,98 @@
+// v2.1 触发逻辑自测 — 模拟真实任务输入,验证判断 0 硬门槛是否拦得住
+import { planDispatch, hardTrigger, isConversation, canSelfExecute, selectProfile, VERSION, SKILL_NAME, lessonEntry } from '../lib/index.mjs';
+
+let pass = 0, fail = 0;
+const check = (name, got, want) => {
+  const ok = JSON.stringify(got) === JSON.stringify(want);
+  ok ? pass++ : fail++;
+  console.log(`${ok ? '✅' : '❌'} ${name}`);
+  if (!ok) console.log(`     got  = ${JSON.stringify(got)}\n     want = ${JSON.stringify(want)}`);
+};
+
+console.log(`\n== ${SKILL_NAME} v${VERSION} 触发逻辑自测 ==\n`);
+
+// ── 判断 0:硬门槛(v2.0 失效场景必须被拦住)──────────────────────────
+check('H1 git 路径(失效原场景):必须派,走 claude-p',
+  planDispatch('/opt/data/git-repos/skill-heterarchy/ 这是项目级任务'),
+  { action: 'dispatch', hit: 'H1', label: 'git 仓库路径', profile: 'fullstack', mechanism: 'claude-p', parallel: false });
+
+check('H1 相对路径 git-repos',
+  hardTrigger('改一下 ./git-repos/foo/SKILL.md')?.id, 'H1');
+
+check('H1 .git 目录',
+  hardTrigger('看看 .git/config 里有什么')?.id, 'H1');
+
+check('H2 git clone → claude-p',
+  planDispatch('git clone https://github.com/x/y.git 然后跑起来').mechanism, 'claude-p');
+
+check('H2 git commit → claude-p',
+  planDispatch('帮我 git commit 并 push 一下').hit, 'H2');
+
+check('H3 构建 → delegate-task',
+  planDispatch('帮我 build 一下这个包的产物').mechanism, 'delegate-task');
+
+check('H3 重构多文件',
+  planDispatch('重构多文件里的日志模块').hit, 'H3');
+
+check('H3 CI 关键词',
+  planDispatch('配一下 CI 流水线').hit, 'H3');
+
+check('H4 技能开发/安装 → claude-p',
+  planDispatch('开发一个 skill 并安装到 /opt/data/skills/'),
+  { action: 'dispatch', hit: 'H4', label: '技能开发/安装', profile: 'fullstack', mechanism: 'claude-p', parallel: false });
+
+check('H4 改 SKILL.md',
+  planDispatch('改 SKILL.md 的 frontmatter 版本号').hit, 'H4');
+
+check('H5 用户显式"项目级任务" → 派(profile 由文本再判)',
+  planDispatch('这是个项目级任务,你来安排'),
+  { action: 'dispatch', hit: 'H5', label: '用户显式声明', profile: 'fullstack', mechanism: 'delegate-task', parallel: false });
+
+check('H5 "派 CC 做" → 派',
+  planDispatch('这个派 CC 做').hit, 'H5');
+
+check('H6 批量容器 → devops 并行',
+  planDispatch('把 34 台容器的配置全部更新一遍', { parallel: true }),
+  { action: 'dispatch', hit: 'H6', label: '批量对象', profile: 'devops', mechanism: 'delegate-task', parallel: true });
+
+check('H6 全舰队',
+  planDispatch('全舰队逐台检查健康状态').hit, 'H6');
+
+// ── 判断 1:沟通不误伤 ────────────────────────────────────────────────
+check('沟通:你好 → reply', planDispatch('你好').action, 'reply');
+check('沟通:收到 → reply', planDispatch('收到!').action, 'reply');
+check('沟通:谢谢 → reply', planDispatch('谢谢').action, 'reply');
+check('isConversation 空串 → true', isConversation(''), true);
+
+// ── 判断 2:整条请求累计预算(v2.1 修正口径)──────────────────────────
+check('累计 2 调用 → 允许自做', canSelfExecute({ cumulativeToolCalls: 2 }), true);
+check('累计 3 调用 → 必须派发', canSelfExecute({ cumulativeToolCalls: 3 }), false);
+check('已知信息 → 允许自做', canSelfExecute({ known: true }), true);
+check('无计划 → 不臆断自做', canSelfExecute(null), false);
+
+// 关键回归:单步 ≤2 但累计超预算,不得自做
+check('★ 单步 2 但累计 12 → 走派发(v2.0 于此失守)',
+  planDispatch('顺手把报告里的错别字改一下', { plan: { toolCalls: 2, cumulativeToolCalls: 12 } }).action,
+  'dispatch');
+
+// 非硬门槛的软任务仍可自做
+check('软任务累计 1 调用 → self',
+  planDispatch('把这句话翻译成英文', { plan: { cumulativeToolCalls: 1 } }).action, 'self');
+
+// ── 判断 3:Profile 自动映射 ──────────────────────────────────────────
+check('Profile:小说 → writer', selectProfile('写一章小说'), 'writer');
+check('Profile:调研 → researcher', selectProfile('调研一下竞品'), 'researcher');
+check('Profile:部署 → devops', selectProfile('部署到线上'), 'devops');
+check('Profile:兜底 → fullstack', selectProfile('随便弄弄'), 'fullstack');
+
+// ── 判断 4:需要项目上下文 → claude-p ────────────────────────────────
+check('needsProjectContext → claude-p',
+  planDispatch('帮我看看这个功能', { needsProjectContext: true }).mechanism, 'claude-p');
+
+// ── 教训库条目 ───────────────────────────────────────────────────────
+check('lessonEntry 产出表格行',
+  lessonEntry({ date: '2026-10-08', hit: 'H1', action: '自己改 SKILL.md', should: 'fullstack/CC', cause: '跳过判断0' }),
+  '| 2026-10-08 | H1 | 自己改 SKILL.md | fullstack/CC | 跳过判断0 |');
+
+console.log(`\n== 结果:${pass} 通过 / ${fail} 失败 ==\n`);
+process.exit(fail === 0 ? 0 : 1);

+ 12 - 0
dist/skills/fmode-heterarchy/skill-package-manifest.json

@@ -0,0 +1,12 @@
+{
+  "name": "skill-heterarchy",
+  "version": "2.1.0",
+  "description": "\u5185\u5f02\u5c42\u8ba4\u77e5\u534f\u540c",
+  "skills": [
+    {
+      "name": "skill-heterarchy",
+      "path": "skills/skill-heterarchy/SKILL.md"
+    }
+  ],
+  "install": "npx --yes skill-heterarchy@latest workspace"
+}

+ 102 - 0
dist/skills/fmode-image/SKILL.md

@@ -0,0 +1,102 @@
+---
+name: "skill-image"
+description: "触发词:生成架构图/场景图/插图/产品设计图/爆炸图。走API出白底PNG,自动读Key,¥0.3-0.5/张。"
+version: "1.1.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [生成架构图/场景图/插图/产品设计图/爆炸图]
+---
+
+# skill-image v1.1.0 — AI图像生成器
+
+7种模式 · 零依赖纯ESM · 自动读API Key · 白底PNG直接放PPT
+
+## 七种模式速查
+
+| 模式 | 尺寸 | 成本 | 用途 |
+|------|------|------|------|
+| `--app` | 1792×1024 | ¥0.5 | **应用界面**(投入产出比最高) |
+| `--arch` | 1792×1024 | ¥0.5 | 架构图/分层/闭环 |
+| `--explode` | 1792×1024 | ¥0.5 | **产品爆炸图/内部结构拆解** |
+| `--product` | 1792×1024 | ¥0.5 | **产品设计图**(硬件/工业设计) |
+| `--slide` | 1920×1080 | ¥0.5 | 整页PPT(封面/金句页) |
+| `--scene` | 1024×1024 | ¥0.3 | 场景插图/痛点 |
+| `--detail` | 1024×1024 | ¥0.3 | 零件/材质特写 |
+
+## 安装
+```bash
+npm install -g skill-image
+```
+
+## 快速使用
+```bash
+# 应用界面(推荐!)
+npx skill-image --app "标题'内容合规系统'左侧导航5项,顶部指标卡3张" app-demo
+
+# 架构图
+npx skill-image --arch "Harness五层引擎,从上到下数据流" harness
+
+# 产品设计图(硬件/工业产品)
+npx skill-image --product "一款智能水杯的三视图+爆炸图组合,白底,科技感渲染" cup-design
+
+# 产品爆炸图(零件分解)
+npx skill-image --explode "智能手表爆炸图:表盘/表圈/电池/主板/表带分层分解" watch-explode
+
+# 场景插图(省40%成本)
+npx skill-image --scene "法务团队深夜加班堆满稿件" scene
+
+# 整页PPT
+npx skill-image --slide "封面:内容营销的超级预审Agent" cover
+```
+
+## 输出控制
+- `FMODE_API_KEY` 环境变量(必设)
+- `SKILL_IMAGE_OUTPUT` 输出目录(默认当前目录)
+
+## 最佳实践
+
+### --app 界面图
+- 结构=左导航+顶部指标卡+中间表格+右侧详情,指标卡带同比↑瞬间真实
+- 所有数字必须与讲稿/产品实际数据一致(AI会编造)
+
+### --arch 架构图
+- 右侧数据卡是灵魂,所有数字必须与讲稿一致
+- 层级名用系统真实命名,配色走品牌色
+
+### --product 产品设计图(硬件沉淀)
+- **适合**:硬件产品(水杯/手表/家居/电子)、工业设计、产品概念图
+- **多角度组合**:一次 prompt 里要"主视图+侧视图+顶视图+3/4视角"多角度
+- **白底必须**:`纯白背景` 写进 prompt,方便放PPT/对比图
+- **材质与工艺**:指定材质(磨砂/金属/玻璃/镜面)+工艺感(倒角/精密加工)
+- **场景化渲染**:加"使用场景/人手持/桌面摆放",比纯产品更有说服力
+- **爆炸图搭配**:产品设计图常配一张 `--explode` 展示内部结构
+
+### --explode 产品爆炸图
+- **适合**:展示产品内部结构、零件分解、组装关系、数据流向
+- **分层分解**:从外壳→内部组件→核心芯片逐层炸开
+- **数据流向**:标注"数据从传感器→主板→显示"流向箭头
+- **装配关系**:用虚线/序号标出装配顺序,工程感强
+- **纯白底**:方便放PPT技术页
+
+### --scene 场景图
+- 给情绪词(焦虑/惊喜)比纯描述生动十倍
+- 对比场景(Before/After)冲击最大
+
+### --slide 整页PPT
+- 封面/金句/章节过渡页最稳(文字少),内容页素材图+HTML排版
+
+## 独立调用(ESM import)
+```js
+import { gen, genArch, genApp, genExplode, genProduct, genScene, genSlide, batch } from "skill-image";
+await genProduct("智能水杯多角度+爆炸图组合,白底", "cup");
+await genExplode("手表爆炸分层分解,标注数据流", "watch-explode");
+```
+
+## 版本历史
+- **v0.1.0**:首发 ESM 化(6种通用模式:app/arch/explode/scene/detail/slide)
+- **v0.2.0**:新增 `--product` 产品设计模式 + 产品设计/爆炸图最佳实践
+- **v0.2.1**:更名至 `skill-image`,bin 增加 install/workspace/check/smoke 安装子命令
+
+## 链接
+- GitHub:https://github.com/fmodecn/skill-image
+- npm:https://www.npmjs.com/package/skill-image

+ 130 - 0
dist/skills/fmode-listen/SKILL.md

@@ -0,0 +1,130 @@
+---
+name: "skill-listen"
+description: "把录音文件转写成文字(讯飞「录音文件转写」LFASR),通过 Fmode 网关 /api/listen/transcribe 完成。适用场景:(1) 会议/采访/课程录音转文字, (2) 视频先抽音轨再转写, (3) 需要中英多语种/方言识别, (4) 需要说话人分离的多人对话整理。讯飞凭据仅服务端持有,客户端只需 fmode token,服务端按音频真实时长计费。 触发词:音频处理、listen、transcribe、asr、speech-to-text、diarization"
+version: "1.1.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [音频处理, listen, transcribe, asr, speech-to-text, diarization]
+---
+
+# Fmode Listen — 录音转写网关技能
+
+## Overview
+
+本技能把本地音频文件交给 **Fmode 网关** `POST /api/listen/transcribe`,由服务端调用讯飞
+「录音文件转写」(LFASR 异步转写)完成识别。
+
+关键约束:
+- **客户端不持有讯飞凭据**——appId/apiKey/secretKey 仅在服务端。客户端只需携带 **fmode token**。
+- **计费在服务端**:服务端拿到真实音频时长后按 `ceil(分钟) × 单价` 扣费(与其它 Fmode APIG 模型同一套 newapi 计量统计),不足 1 分钟按 1 分钟计。余额不足返回 402 + 充值链接。
+- 音频当前由网关中转上传讯飞(客户端 → 网关 → 讯飞),无需本地存储讯飞密钥。
+
+> 与 `fmode-ffmpeg` 配合:视频或大体积音频先用 `npx fmode-ffmpeg exec -- -y -i input.mp4 -vn -ar 16000 -ac 1 out.wav` 转成 16kHz 单声道 wav,再交给本技能,能显著降低上传体积、提高识别稳定性。
+
+## 鉴权(必读)
+
+客户端只需提供 **fmode token**,运行器按以下优先级自动解析(第0级自举 → 回落):
+
+0. **第0级自举**:`FMODE_SESSION_TOKEN` 或 `~/.fmode/config.json` 的 `sessionToken` → 调 fmode API 动态换取 API token(登录 FMODE Studio 即可,无需手工配置;token 仅内存持有,不落盘不进日志)
+1. 环境变量 `FMODE_API_TOKEN`
+2. `~/.fmode/config.json` 的 `fmodeApiToken` / `newapiToken` 字段(FmodeStudio 保存配置后写入)
+3. 项目 `./.fmode/config.json` 的 `fmodeApiToken` / `newapiToken` 字段
+4. `~/.claude/settings.json`(含 `settings.local.json` / 项目级 `.claude/`)的 `env.ANTHROPIC_AUTH_TOKEN`——**这就是 FmodeCode / FmodeCode 的 `sk-` token,运行器会自动读取,无需手动配置**。仅当 `sk-` 开头(排除真 Anthropic 的 `sk-ant-`)且 base 指向 fmode 时才采纳。
+
+> 这把 `sk-` 就是你在 FmodeCode / FmodeCode / FmodeStudio 里配的 fmode newapi token,装完技能即可命中。若运行器报「未找到 token」,那是缺 token、**不是「用不了」**——请勿点任何付费/充值弹窗,按上面任一来源补上即可。
+>
+> 不要在任何示例或代码里写讯飞 appId/apiKey/secretKey——它们只属于服务端。
+
+## 用法
+
+### 命令行直接转写
+
+```bash
+# 基础:转写一个录音文件(自动探测时长用于计费预估)
+npx --yes skill-listen@latest transcribe -- meeting.mp3
+
+# 指定语言 + 说话人分离 + 写出完整 JSON
+npx --yes skill-listen@latest transcribe -- meeting.mp3 \
+  --language autodialect --diarize --speakers 3 --out result.json
+
+# 自定义网关(默认 https://server.fmode.cn/api/listen)
+npx --yes skill-listen@latest transcribe -- meeting.mp3 --gateway https://server.fmode.cn/api/listen
+```
+
+`transcribe` 后必须加 `--`,其后参数透传给运行器。stdout 输出纯文本转写结果;`--out` 额外写出网关返回的完整 JSON。
+
+### 在 Node 脚本中调用
+
+技能目录被复制进 `.claude/skills/` 时**不含 node_modules**,运行器是零依赖的 ESM,可直接 import:
+
+```js
+import { transcribeFile, probeDurationMs, resolveApiToken }
+  from './scripts/listen-runner.mjs';
+
+const { text, segments, raw } = await transcribeFile({
+  filePath: 'meeting.mp3',
+  language: 'autodialect',   // 默认 autodialect(中英自动+方言)
+  diarize: true,             // 说话人分离
+  speakers: 3,               // 预期说话人数(可选)
+  // durationMs: 123000,     // 可选;缺省自动 ffprobe 探测
+  // gateway: 'https://server.fmode.cn/api/listen',
+  // token: '...',           // 可选;缺省自动解析 fmode token
+});
+console.log(text);
+```
+
+## 参数说明
+
+| 参数 | 含义 | 默认 |
+|------|------|------|
+| `<audioFile>` | 本地音频文件路径(位置参数) | 必填 |
+| `--language` | 识别语言,如 `autodialect`(中英+方言自动)/ `cn` / `en` | `autodialect` |
+| `--diarize` | 开启说话人分离 | 关 |
+| `--speakers N` | 预期说话人数(配合 `--diarize`) | 自动 |
+| `--duration <ms>` | 音频时长(毫秒),用于计费预估;缺省自动探测 | 自动 |
+| `--gateway <url>` | 网关基址 | `https://server.fmode.cn/api/listen` |
+| `--out <file>` | 写出网关返回的完整 JSON | 不写 |
+
+## 网关接口
+
+```
+POST {gateway}/transcribe?fileName=meeting.mp3&duration=123000&language=autodialect&diarize=true&speakers=3
+Headers: Authorization: Bearer <fmode token>
+         Content-Type: application/octet-stream
+Body:    原始音频字节
+```
+
+成功响应:
+```json
+{ "code": 200, "data": { "text": "...", "segments": [ ... ] } }
+```
+
+余额不足:
+```json
+{ "code": 402, "mess": "余额不足,请充值后重试", "rechargeUrl": "..." }
+```
+
+## 计费口径
+
+- 按**音频真实时长**计费:`ceil(音频分钟) × 单价`,不足 1 分钟按 1 分钟计。
+- 服务端在转写成功、拿到讯飞返回的真实时长后扣费;扣费走与其它 Fmode 模型同一套 newapi 计量,用量在统一后台可查。
+- 客户端传的 `duration` 仅用于发起前的余额预校验,最终以服务端真实时长为准。
+
+## 安装为 FmodeCode / FmodeCode 技能
+
+```bash
+# 项目级 → ./.claude/skills/skill-listen
+npx --yes skill-listen@latest workspace
+
+# 用户级 → ~/.claude/skills/skill-listen
+npx --yes skill-listen@latest install
+```
+
+安装后可直接提示 FmodeCode / FmodeCode,例如:`把 meeting.mp3 转写成文字,开启说话人分离。`
+
+## 注意事项
+
+- 长音频转写是异步过程,网关会在服务端轮询讯飞直到完成再返回,请求耗时随时长增加,调用方注意超时设置。
+- 大文件/视频先用 `fmode-ffmpeg` 压成 16kHz 单声道 wav 再转写,省带宽且更稳。
+- 出现 401:检查 fmode token;出现 402:余额不足,按返回的 `rechargeUrl` 充值。
+- 切勿在客户端写入讯飞密钥;凭据只在服务端。

+ 388 - 0
dist/skills/fmode-listen/scripts/listen-runner.mjs

@@ -0,0 +1,388 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+/**
+ * skill-listen 录音转写网关客户端
+ *
+ * 通过 Fmode 网关 POST /api/listen/transcribe 调用讯飞「录音文件转写」。
+ * 客户端不持有讯飞凭据——凭据仅在服务端。客户端只需携带 fmode token,
+ * 服务端鉴权后调用讯飞并按音频时长计费(ceil(分钟) × 单价)。
+ *
+ * 导出:
+ *   - resolveApiToken()    第0级 sessionToken 自举 + 回落链获取 fmode token
+ *   - probeDurationMs()    用 ffprobe / fmode-ffmpeg 探测音频时长(可选)
+ *   - transcribeFile()     上传本地音频文件并返回转写结果
+ *
+ * CLI:
+ *   node listen-runner.mjs <audioFile> [--duration <ms>] [--language autodialect]
+ *        [--diarize] [--speakers N] [--gateway <baseUrl>] [--out <file.json>]
+ */
+
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+import { spawnSync } from 'child_process';
+
+// ============================================================
+// 配置
+// ============================================================
+
+// 网关基址:环境变量 > 默认线上地址
+const DEFAULT_GATEWAY = process.env.FMODE_LISTEN_GATEWAY
+  || 'https://server.fmode.cn/api/listen';
+
+// ============================================================
+// Token 解析(与 voc / fmode-vision 共享层一致)
+// ============================================================
+//
+// 关键修复:fmode 的 newapi SK 默认就是 FmodeCode / FmodeCode 的 env.ANTHROPIC_AUTH_TOKEN,
+// 存在 ~/.claude/settings.json(及 settings.local.json / 项目级 .claude/)。
+// 旧实现只读进程环境变量 ANTHROPIC_AUTH_TOKEN,从不读这个文件——用户按 FmodeCode / FmodeCode
+// 正常方式配好 SK,技能却「看不见」→ 判缺 token → 掉进旧付费弹窗死循环。
+// 这里直接读该文件,且校验 sk- 开头、排除真 Anthropic sk-ant-、base 指向 fmode。
+//
+// 取值优先级(第0级为 sessionToken 自举,其余为回落链):
+//   0. sessionToken 自举:FMODE_SESSION_TOKEN 环境变量或 ~/.fmode/config.json 的
+//      sessionToken → POST https://server.fmode.cn/api/fmode/voc-skill/install-prompt
+//      → 从返回 prompt 中提取 fmode API token(sk- 开头,排除 sk-ant-)。
+//      ⚠️ token 仅内存持有,不落盘不进日志。
+//   1. 显式入参 token / 环境变量 FMODE_API_TOKEN
+//   2. ~/.fmode/config.json → fmodeApiToken / newapiToken(FmodeStudio 保存写这里)
+//   3. <cwd>/.fmode/config.json → fmodeApiToken / newapiToken
+//   4. 进程注入的 ANTHROPIC_AUTH_TOKEN(FmodeCode / FmodeCode 把 settings.env 注入子进程时)
+//   5. ~/.claude/settings.json 等文件里的 env.ANTHROPIC_AUTH_TOKEN(独立运行未被注入时)
+
+const FMODE_API_BASE = (process.env.FMODE_API_BASE || 'https://server.fmode.cn').replace(/\/$/, '');
+
+/** 第0级:解析 sessionToken(env FMODE_SESSION_TOKEN → ~/.fmode/config.json)。找不到返回 null。 */
+function resolveSessionToken() {
+  if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
+  const p = path.join(os.homedir(), '.fmode', 'config.json');
+  try {
+    if (!fs.existsSync(p)) return null;
+    const cfg = JSON.parse(fs.readFileSync(p, 'utf-8').replace(/^/, ''));
+    const t = cfg.sessionToken || (cfg.user && cfg.user.sessionToken) || null;
+    return t && String(t).trim() ? String(t).trim() : null;
+  } catch { return null; }
+}
+
+/**
+ * 第0级:sessionToken → fmode API token(自举)。
+ * 服务端唯一以 session 鉴权并返回 token 本体的端点是 voc-skill 安装指令生成器;
+ * token 内嵌在返回 prompt 文本中,这里提取后仅内存持有(不落盘不进日志)。
+ * @returns {Promise<string|null>} 提取失败返回 null(调用方回落下一级)。
+ */
+async function fetchApiTokenFromSession(sessionToken) {
+  if (!sessionToken) return null;
+  try {
+    const res = await fetch(`${FMODE_API_BASE}/api/fmode/voc-skill/install-prompt`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'x-parse-session-token': sessionToken },
+      body: JSON.stringify({ channel: 'FmodeCode', scope: 'user', source: 'skill-token-bootstrap' }),
+      signal: AbortSignal.timeout(15000),
+    });
+    if (!res.ok) return null;
+    const body = await res.json().catch(() => null);
+    const prompt = body && body.data && typeof body.data.prompt === 'string' ? body.data.prompt : '';
+    const m = prompt.match(/sk-(?!ant-)[A-Za-z0-9_-]{8,}/);
+    return m ? m[0] : null;
+  } catch { /* 网络失败一律回落,不泄露错误细节 */ }
+  return null;
+}
+
+function readJsonMaybe(filePath) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return {};
+    return JSON.parse(fs.readFileSync(filePath, 'utf-8').replace(/^\uFEFF/, ''));
+  } catch {
+    return {};
+  }
+}
+
+// 合并读取 FmodeCode / FmodeCode 的 settings env(用户级 + 项目级,含 .local 覆盖文件)。
+function readClaudeSettingsEnv() {
+  const files = [
+    path.join(os.homedir(), '.claude', 'settings.json'),
+    path.join(os.homedir(), '.claude', 'settings.local.json'),
+    path.join(process.cwd(), '.claude', 'settings.json'),
+    path.join(process.cwd(), '.claude', 'settings.local.json'),
+  ];
+  const merged = {};
+  for (const filePath of files) {
+    const json = readJsonMaybe(filePath);
+    const env = json && typeof json.env === 'object' && json.env ? json.env : null;
+    if (!env) continue;
+    for (const [key, value] of Object.entries(env)) {
+      if (merged[key] === undefined && typeof value === 'string' && value.trim()) {
+        merged[key] = value;
+      }
+    }
+  }
+  return merged;
+}
+
+// 仅当 ANTHROPIC_AUTH_TOKEN 看起来是 fmode 的 newapi SK 时才采纳:
+// - 必须 sk- 开头,且排除真 Anthropic 官方 key(sk-ant- 开头);
+// - 若设了 ANTHROPIC_BASE_URL,必须指向 fmode(否则这把 token 是发往别处的)。
+function pickFmodeAnthropicToken(env) {
+  const token = env && typeof env.ANTHROPIC_AUTH_TOKEN === 'string' ? env.ANTHROPIC_AUTH_TOKEN.trim() : '';
+  if (!token || !/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
+  const base = String((env && (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_BASE)) || '').toLowerCase();
+  if (base && !base.includes('fmode')) return '';
+  return token;
+}
+
+/**
+ * 获取 fmode API token。加载链优先级(第0级自举 → 回落):
+ *   0. sessionToken 自举(FMODE_SESSION_TOKEN 或 ~/.fmode/config.json)→ fmode API 换取
+ *   1. 环境变量 FMODE_API_TOKEN
+ *   2. ~/.fmode/config.json → fmodeApiToken / newapiToken(FmodeStudio 保存写这里)
+ *   3. ~/.claude/settings.json 等 → env.ANTHROPIC_AUTH_TOKEN(sk- 开头非 sk-ant-)
+ *   4. <project>/.fmode/config.json → fmodeApiToken / newapiToken
+ *
+ * @param {string} [projectRoot] 项目根目录,默认 process.cwd()
+ * @returns {Promise<{ token: string, source: string }>}
+ */
+export async function resolveApiToken(projectRoot) {
+  // 第0级自举:sessionToken → fmode API 换取
+  const sessionToken = resolveSessionToken();
+  if (sessionToken) {
+    const bootstrapped = await fetchApiTokenFromSession(sessionToken);
+    if (bootstrapped) {
+      return { token: bootstrapped, source: 'level0:sessionToken->fmode-api' };
+    }
+    // 自举失败:明确报错指向重新登录,随后回落
+    console.error('sessionToken 存在但换取 fmode API token 失败——sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN');
+  }
+
+  if (process.env.FMODE_API_TOKEN) {
+    return { token: process.env.FMODE_API_TOKEN, source: 'env:FMODE_API_TOKEN' };
+  }
+
+  const userConfigPath = path.join(os.homedir(), '.fmode', 'config.json');
+  const userToken = readTokenFromConfig(userConfigPath);
+  if (userToken) {
+    return { token: userToken, source: userConfigPath };
+  }
+
+  const root = projectRoot || process.cwd();
+  const projectConfigPath = path.join(root, '.fmode', 'config.json');
+  const projectToken = readTokenFromConfig(projectConfigPath);
+  if (projectToken) {
+    return { token: projectToken, source: projectConfigPath };
+  }
+
+  // FmodeCode / FmodeCode 默认入口:进程注入的 ANTHROPIC_AUTH_TOKEN(sk-、base 指向 fmode)
+  const injected = pickFmodeAnthropicToken(process.env);
+  if (injected) {
+    return { token: injected, source: 'env:ANTHROPIC_AUTH_TOKEN' };
+  }
+
+  // 兜底:直接读 ~/.claude/settings.json 等文件里的 env.ANTHROPIC_AUTH_TOKEN
+  const claudeEnv = readClaudeSettingsEnv();
+  const fromSettings = pickFmodeAnthropicToken(claudeEnv);
+  if (fromSettings) {
+    return { token: fromSettings, source: '~/.claude/settings.json:env.ANTHROPIC_AUTH_TOKEN' };
+  }
+
+  throw new Error(
+    '未找到 Fmode API token。请通过以下任一方式提供(第0级自举 → 回落):\n' +
+    '  0. 登录 FMODE Studio 后自动自举(FMODE_SESSION_TOKEN 或 ~/.fmode/config.json 的 sessionToken)\n' +
+    '  1. 环境变量 FMODE_API_TOKEN\n' +
+    '  2. ~/.fmode/config.json 中 fmodeApiToken 字段(FmodeStudio 保存配置后写入)\n' +
+    '  3. 项目 .fmode/config.json 中 fmodeApiToken 字段\n' +
+    '  4. ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(FmodeCode / FmodeCode 的 sk- token,会自动读取)\n' +
+    '  注意:这是缺 token,不是「用不了」——请勿点任何付费/充值弹窗。'
+  );
+}
+
+function readTokenFromConfig(configPath) {
+  try {
+    if (!fs.existsSync(configPath)) return null;
+    const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8').replace(/^\uFEFF/, ''));
+    return cfg.fmodeApiToken || cfg.newapiToken || null;
+  } catch {
+    return null;
+  }
+}
+
+// ============================================================
+// 音频时长探测(可选,用于计费预估)
+// ============================================================
+
+/**
+ * 探测音频时长(毫秒)。优先用系统 ffprobe,其次 fmode-ffmpeg 的 ffprobe,
+ * 失败则返回 0(服务端会以真实时长计费)。
+ *
+ * @param {string} audioPath
+ * @returns {number} 毫秒,探测失败返回 0
+ */
+export function probeDurationMs(audioPath) {
+  const candidates = [];
+  if (process.env.FFPROBE_PATH) candidates.push(process.env.FFPROBE_PATH);
+  candidates.push('ffprobe');
+
+  const args = [
+    '-v', 'error',
+    '-show_entries', 'format=duration',
+    '-of', 'default=noprint_wrappers=1:nokey=1',
+    audioPath,
+  ];
+
+  for (const bin of candidates) {
+    try {
+      const r = spawnSync(bin, args, { encoding: 'utf-8' });
+      if (r.status === 0 && r.stdout) {
+        const sec = parseFloat(String(r.stdout).trim());
+        if (Number.isFinite(sec) && sec > 0) return Math.round(sec * 1000);
+      }
+    } catch { /* try next */ }
+  }
+
+  // 兜底:尝试 npx fmode-ffmpeg 的 ffprobe
+  try {
+    const r = spawnSync('npx', ['--yes', 'fmode-ffmpeg@latest', 'probe', '--', ...args], { encoding: 'utf-8' });
+    if (r.status === 0 && r.stdout) {
+      const sec = parseFloat(String(r.stdout).trim());
+      if (Number.isFinite(sec) && sec > 0) return Math.round(sec * 1000);
+    }
+  } catch { /* ignore */ }
+
+  return 0;
+}
+
+// ============================================================
+// 转写
+// ============================================================
+
+/**
+ * 上传本地音频文件到网关并转写。
+ *
+ * @param {Object} opts
+ * @param {string} opts.filePath      本地音频文件路径
+ * @param {number} [opts.durationMs]  音频时长(毫秒);缺省自动探测
+ * @param {string} [opts.language]    识别语言,默认 autodialect
+ * @param {boolean} [opts.diarize]    是否说话人分离
+ * @param {number} [opts.speakers]    预期说话人数
+ * @param {string} [opts.gateway]     网关基址,默认 DEFAULT_GATEWAY
+ * @param {string} [opts.token]       手动传入 fmode token,否则自动解析
+ * @returns {Promise<{ text: string, segments: any[], raw: object }>}
+ */
+export async function transcribeFile(opts) {
+  const {
+    filePath, language = 'autodialect', diarize = false,
+    speakers, gateway = DEFAULT_GATEWAY,
+  } = opts;
+
+  if (!filePath || !fs.existsSync(filePath)) {
+    throw new Error(`音频文件不存在: ${filePath}`);
+  }
+
+  const token = opts.token || (await resolveApiToken()).token;
+  const audio = fs.readFileSync(filePath);
+  const fileName = path.basename(filePath);
+
+  let durationMs = Number(opts.durationMs || 0);
+  if (!durationMs) durationMs = probeDurationMs(filePath);
+
+  const params = new URLSearchParams();
+  params.set('fileName', fileName);
+  if (durationMs) params.set('duration', String(durationMs));
+  if (language) params.set('language', language);
+  if (diarize) params.set('diarize', 'true');
+  if (speakers) params.set('speakers', String(speakers));
+
+  const url = `${gateway.replace(/\/$/, '')}/transcribe?${params.toString()}`;
+
+  const res = await fetch(url, {
+    method: 'POST',
+    headers: {
+      'Authorization': `Bearer ${token}`,
+      'Content-Type': 'application/octet-stream',
+    },
+    body: audio,
+  });
+
+  let body;
+  const rawText = await res.text();
+  try { body = JSON.parse(rawText); } catch { body = { raw: rawText }; }
+
+  if (res.status === 402) {
+    const url = body && body.rechargeUrl ? `\n充值链接:${body.rechargeUrl}` : '';
+    throw new Error(`余额不足,请充值后重试。${url}`);
+  }
+  if (!res.ok || (body && body.code && body.code >= 400)) {
+    const mess = (body && (body.mess || body.error)) || rawText || `HTTP ${res.status}`;
+    throw new Error(`转写失败 (HTTP ${res.status}): ${mess}`);
+  }
+
+  const data = (body && body.data) || {};
+  return {
+    text: data.text || '',
+    segments: data.segments || [],
+    raw: body,
+  };
+}
+
+// ============================================================
+// CLI
+// ============================================================
+
+function parseCliArgs(argv) {
+  const args = { language: 'autodialect', diarize: false };
+  const positional = [];
+  for (let i = 0; i < argv.length; i++) {
+    const t = argv[i];
+    if (t === '--duration' && argv[i + 1]) args.durationMs = Number(argv[++i]);
+    else if (t.startsWith('--duration=')) args.durationMs = Number(t.slice(11));
+    else if (t === '--language' && argv[i + 1]) args.language = argv[++i];
+    else if (t.startsWith('--language=')) args.language = t.slice(11);
+    else if (t === '--diarize') args.diarize = true;
+    else if (t === '--speakers' && argv[i + 1]) args.speakers = Number(argv[++i]);
+    else if (t.startsWith('--speakers=')) args.speakers = Number(t.slice(11));
+    else if (t === '--gateway' && argv[i + 1]) args.gateway = argv[++i];
+    else if (t.startsWith('--gateway=')) args.gateway = t.slice(10);
+    else if (t === '--out' && argv[i + 1]) args.out = argv[++i];
+    else if (t.startsWith('--out=')) args.out = t.slice(6);
+    else if (!t.startsWith('--')) positional.push(t);
+  }
+  args.filePath = positional[0];
+  return args;
+}
+
+async function main() {
+  const args = parseCliArgs(process.argv.slice(2));
+  if (!args.filePath) {
+    console.error('用法: node listen-runner.mjs <audioFile> [--duration <ms>] [--language autodialect] [--diarize] [--speakers N] [--gateway <baseUrl>] [--out <file.json>]');
+    process.exit(1);
+  }
+  const result = await transcribeFile(args);
+  if (args.out) {
+    fs.writeFileSync(args.out, JSON.stringify(result.raw, null, 2));
+    console.error(`已写入: ${args.out}`);
+  }
+  console.log(result.text);
+}
+
+// 仅在直接运行时执行 CLI
+const isMain = (() => {
+  try {
+    return import.meta.url === `file://${process.argv[1]}`
+      || import.meta.url.endsWith(path.basename(process.argv[1] || ''));
+  } catch { return false; }
+})();
+
+if (isMain) {
+  main().catch((err) => {
+    console.error(`skill-listen: ${err.message}`);
+    process.exit(1);
+  });
+}

+ 129 - 0
dist/skills/fmode-product-lab/SKILL.md

@@ -0,0 +1,129 @@
+---
+name: "skill-product-lab"
+description: "触发词:新品研发、产品创新、产品开发、健康快消产品。VOC+KANO+市场+价格带+竞争+定位+错位竞争+5视图分析+AI生图+HTML全案。"
+version: "1.1.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [新品研发, 产品创新, 产品开发, 健康快消产品]
+---
+
+# skill-product-lab v1.1.0 | 新品研发 Lab
+
+> **消费品/食品新品研发全案生成器**
+> 输入一段产品需求 → 8大方法论分析(VOC/KANO/市场/价格带/竞争/定位/错位竞争/5视图)→ 产品概念 → AI生成4类概念图 → HTML全案长页。
+
+## 快速开始
+
+```bash
+# 全案分析
+npx skill-product-lab --task "面向25-35岁白领的抗氧化胶原蛋白软糖,含VC,电商渠道"
+npx skill-product-lab --task "儿童益生菌固体饮料,母婴渠道" --output plan.html
+```
+
+## 8大方法论板块
+
+### 1. VOC 用户之声
+- 调研方法:小红书/抖音评论分析、电商评价NLP、问卷N=500+、KOC深访
+- 用户原声聚类(5类高频痛点:没感觉/难坚持/看不懂/太贵/怕副作用)
+- **KANO 分类**:基础/绩效/兴奋/无差异/反向 → 优先级排序
+
+### 2. 市场份额
+- 赛道规模/年增长率/CR5集中度
+- 份额分布(头部/腰部/长尾)
+- 机会空间(形态创新子类占比=切入最快路径)
+
+### 3. 价格带
+- 4档价格带(大众/中端/高端/超高端)日成本与月花费
+- 渠道溢价倍数
+- 目标渠道最优价格带推荐
+
+### 4. 竞争分析
+- 直接竞品与策略弱点(形态老化/功效表达弱/内容适配差/价格战线长)
+- 形态分布(传统70% vs 新形态30%)
+- 差异化空位
+
+### 5. 定位升级
+- 4层级:品类→人群→场景→情感
+- 一句话定位
+
+### 6. 品类定位与错位竞争
+- 品类边界重划(按成分→按场景)
+- 错位竞争6维矩阵(形态/场景/表达/渠道/价格/复购)
+
+### 7. 五视图
+- 市场 / 用户 / 供应链 / 工艺 / 法规
+
+### 8. AI概念图(skill-image 联动)
+- `--product` 产品设计图(包装/外观)
+- `--explode` 技术爆炸图(成分/结构)
+- `--scene` 场景插图(用户使用)
+- `--app` 应用/详情页
+
+## 领域知识库(内置)
+
+```js
+markets: {
+  '维生素矿物质': { size:185亿, growth:8.2%, cr5:46%, leading:[汤臣倍健,善存,Swisse,纽崔莱] },
+  '胶原蛋白':     { size:160亿, growth:15.3%, cr5:30%, leading:[Swisse,姿美堂,汤臣倍健,五个女博士] },
+  '益生菌':       { size:95亿,  growth:12.6%, cr5:38%, leading:[合生元,养乐多,...] },
+  '蛋白补充':     { size:210亿, growth:18.9%, cr5:42% },
+  '睡眠健康':     { size:85亿,  growth:28.6%, cr5:28% },
+  '护眼健康':     { size:110亿, growth:19.8%, cr5:33% },
+  '美容营养':     { size:200亿, growth:25.8%, cr5:25% },
+  '儿童营养':     { size:120亿, growth:14.2%, cr5:35% },
+  '运动营养':     { size:150亿, growth:22.4%, cr5:45% },
+  '功能性食品':   { size:280亿, growth:20.5%, cr5:22% },
+}
+ingredients: [维生素C/胶原蛋白肽/益生菌/GABA/叶黄素/透明质酸钠/辅酶Q10/钙/铁/锌/乳清蛋白/白芸豆/L-茶氨酸/麦角硫因: 限量/宣称/载体]
+channels: [内容电商/传统电商/线下商超/私域: 份额/增长/溢价/包装策略]
+priceTiers: [大众/中端/高端/超高端: 日成本/月花费/人群/价值锚点]
+```
+
+## 产品层级(MDM 框架)
+
+```
+L1 一级大类 → L2 功能赛道 → L3 渠道监管线(跨境HG/大贸CD/保健HF/普通GF/OTC) → L4 产品系列(PV) → L5 SKU(SAP)
+```
+
+## 法规红线
+
+1. **普通食品不得宣称功效**——只能用"含有XX""有助于"暗示性表述
+2. **保健食品必须有蓝帽**——未经备案不得用"改善/辅助/增强"等
+3. **跨境产品按原产国法规**——落地广告仍受中国广告法约束
+4. **儿童食品(12岁以下)**——禁止功能性宣称
+5. **新资源食品**——需核实卫健委公告名单,标注食用限量
+6. **禁用词**:治疗/治愈/根治/特效/100%有效/安全无毒/医生推荐
+
+## 工艺路线对照
+
+| 形态 | 工艺 | 周期 | 保质期 |
+|------|------|------|-------|
+| 片剂 | 湿法制粒→压片 | 3-5天 | 24月 |
+| 软糖 | 化胶→浇注→干燥 | 5-7天 | 18月 |
+| 硬胶囊 | 混合→充填 | 2-3天 | 36月 |
+| 口服液 | 配液→灌装→灭菌 | 3-4天 | 18月 |
+| 固体饮料 | 混合→分装 | 1-2天 | 24月 |
+| 果冻 | 化胶→灌装 | 2-3天 | 12月 |
+
+## 组件式调用
+
+```js
+import { analyze, renderHTML } from 'skill-product-lab';
+const result = analyze('代餐奶昔,高蛋白,运动人群');
+// result: { voc, marketShare, priceBand, competition, reposition, categoryPos, views, recommendation }
+const html = renderHTML(result);
+```
+
+## 环境变量
+- `FMODE_API_KEY`(生成图时必设)
+- `SKILL_IMAGE_OUTPUT`(图片输出目录)
+
+## 版本
+- **v0.1.0**:基础5视图方案
+- **v0.2.0**:全案升级——VOC+KANO+市场份额+价格带+竞争+定位+错位竞争+产品概念图
+- **v0.2.1**:更名 `fmode-product-lab` → `skill-product-lab`;`analyze`/`renderHTML` 正式导出(此前文档承诺但未导出);补齐 skills/、manifest、plugin.json 结构
+
+## 链接
+- GitHub: https://github.com/fmodecn/skill-product-lab
+- npm: https://www.npmjs.com/package/skill-product-lab
+- 关联: skill-image(图片生成)、skill-cdn-deploy(部署)、skill-task-dispatch(委派)

+ 375 - 0
dist/skills/fmode-report-publish/LICENSE

@@ -0,0 +1,375 @@
+Mozilla Public License Version 2.0
+==================================
+
+Copyright (c) 未来飞马
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in 
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.

+ 194 - 0
dist/skills/fmode-report-publish/README.md

@@ -0,0 +1,194 @@
+# skill-report-html · 咨询师级 HTML 长页报告工程
+
+> **未来飞马 — 让AI进化提前发生,让AI落地快人一步**
+
+[![License: MPL-2.0](https://img.shields.io/badge/License-MPL--2.0-brightgreen.svg)](LICENSE)
+[![ESM](https://img.shields.io/badge/module-ESM--only-orange.svg)](#快速开始)
+[![npm](https://img.shields.io/badge/npm-skill--report--html-blue.svg)](https://www.npmjs.com/package/skill-report-html)
+
+---
+
+## 简介
+
+`skill-report-html` 是一套**咨询师级 HTML 长页报告**的制作方法论与工程规范:从零到发布的完整流程、排版规范与可复用资源库,让 AI 产出的报告具备咨询公司交付物的专业观感。
+
+配合 `skill-storage` 可将成果一键发布为公网链接。
+
+本技能适用于 **FmodeAgent / FmodeAgent** 平台,开发由 **FmodeCode / FmodeCode** 执行。
+
+---
+
+## 核心定位
+
+| 维度 | 说明 |
+|------|------|
+| **解决什么** | 长页报告的**结构与排版质量**:信息层级、视觉节奏、专业观感、发布流程 |
+| **不解决什么** | 不生成内容本身、不做数据分析、不做图表计算(内容由分析环节产出) |
+| **与通用 HTML 模板的区别** | 面向咨询交付物场景,规范的是**信息架构与版式系统**,而非单页样式 |
+| **层级** | 系统级(Infrastructure) |
+| **适用平台** | FmodeAgent / FmodeAgent · FmodeCode / FmodeCode |
+
+---
+
+## 核心能力 & 交付物
+
+- 📐 **排版规范** —— 字号阶梯、间距系统、栅格与留白规则
+- 🧱 **结构方法论** —— 封面 → 摘要 → 正文 → 结论 → 附录的标准长页骨架
+- 🎨 **资源库** —— 可复用的版式组件与样式片段
+- 🚀 **发布 SOP** —— 与 `skill-storage` 衔接,产出即可分享的公网链接
+
+交付物:可发布的咨询师级 HTML 长页报告。
+
+---
+
+## 快速开始
+
+### 浏览器(原生 ES Module)
+
+```html
+<script type="module">
+  // 报告是自包含 HTML:直接打开或嵌入即可
+  const resp = await fetch('https://s3.fmode.cn/user/<id>/report/202609/report.html');
+  const html = await resp.text();
+  const doc = new DOMParser().parseFromString(html, 'text/html');
+  document.title = doc.title;
+  document.body.replaceWith(doc.body);
+</script>
+```
+
+### Node.js(ESM)
+
+```javascript
+// 生成报告后,用 skill-storage 发布并取回公网链接
+import { putViaCloudFunction } from 'fmode-storage/skills/fmode-storage/scripts/uploader.mjs';
+
+const r = await putViaCloudFunction(
+  './report.html',
+  'report/202609/report.html',
+  'report',
+);
+console.log(r.url);   // https://s3.fmode.cn/user/<id>/report/202609/report.html
+```
+
+### CLI
+
+```bash
+npm install skill-report-html
+```
+
+---
+
+## FAQ
+
+### 技术概念
+
+**Q1:「咨询师级」具体指什么?**
+指的是一套可检验的版式标准:明确的字号阶梯与行高、统一的间距系统、克制的配色与留白、清晰的信息层级。目标是让报告**第一眼看上去就是专业交付物**,而不是排版随意的网页。
+
+**Q2:长页报告和 PPT 有什么分工?**
+PPT 用于**当面讲解**,节奏由讲者控制;长页报告用于**异步阅读**,读者自行跳读。因此长页必须自带完整的叙事结构——不能依赖口头补充,每个板块都要能独立成立。
+
+**Q3:为什么要先定信息架构再排版?**
+因为排版是为信息层级服务的。先确定「哪些是一级信息、哪些是支撑证据」,版式才有依据;反过来先调样式,往往会得到好看但读不出重点的页面。
+
+**Q4:报告怎么发布出去?**
+配合 `skill-storage`:生成 HTML 后一条命令上传,即可得到可直接转发的公网链接,收件人点开即看,无需下载附件。
+
+### 开源协议(MPL-2.0)
+
+**Q1:MPL-2.0 协议允许我商用吗?**
+允许。MPL-2.0 允许商用,也可用于闭源产品。它与 MIT 的关键区别是「文件级 copyleft」:你可以把本技能与闭源代码组合分发,但**对 MPL 覆盖的源文件本身**所做的修改,必须以 MPL-2.0 公开。
+
+**Q2:使用本技能需要保留版权声明吗?**
+需要。分发时必须保留原始版权声明与许可证全文,并说明 MPL-2.0 覆盖了哪些文件;若修改了 MPL 覆盖的源文件,需以 MPL-2.0 公开这些文件的源码。
+
+**Q3:我可以把本技能改成别的名字再发布吗?**
+可以修改和再分发,但**不可以**使用「未来飞马」「Harness Loop」「RSI」等商标,也不得使用品牌 Slogan 作为产品名或宣传语。版权许可不等于商标授权,详见 [Trademark Notice](#trademark-notice)。
+
+**Q4:MPL-2.0 协议提供担保吗?**
+不提供。本技能按「原样」提供,不附带任何明示或默示担保。
+
+### 业务用户搜索
+
+**Q1:怎么做一个好看的 HTML 报告?**
+用 skill-report-html 的排版规范与长页骨架:先定信息层级,再套用字号阶梯与间距系统,产出的页面具备咨询交付物的专业观感。
+
+**Q2:HTML 报告怎么发给客户?**
+配合 skill-storage 上传后得到公网链接,客户点开即看,无需下载附件、无需登录。
+
+**Q3:长页报告和 PPT 该用哪个?**
+需要当面讲解用 PPT;需要客户自行阅读、反复查阅用长页报告。长页自带完整叙事,适合异步沟通。
+
+**Q4:报告里的图表怎么处理?**
+本技能负责版式与结构规范;数据与图表由上游分析环节产出后嵌入。
+
+---
+
+## GEO 埋点说明
+
+本技能遵循**隐私优先**的 GEO(生成式引擎优化)埋点规范:
+
+- **默认关闭** —— `geoTracking` 默认为 `false`,不开启即不产生任何上报
+- **显式开启** —— 仅当用户主动设置开启后才会上报
+- **最小采集** —— 只采集地区级别信息(国家/大区),**不采集**城市、IP 地址、设备 ID、经纬度
+- **独立模块** —— 埋点逻辑独立于主技能,可单独移除而不影响功能
+- **不阻塞** —— 上报失败静默降级,绝不阻塞主技能逻辑
+
+---
+
+## License
+
+本技能采用 **Mozilla Public License 2.0(MPL-2.0)** 发布,完整原文见 [LICENSE](LICENSE)。
+
+```
+Mozilla Public License Version 2.0
+
+Copyright (c) 未来飞马
+```
+
+## Trademark Notice
+
+> MPL-2.0 governs copyright for source code only.
+> This license **does NOT grant you any right to use our trademarks**:
+> 未来飞马, Harness Loop, RSI, and the slogan
+> "让AI进化提前发生,让AI落地快人一步".
+>
+> You may not use these trademarks in your product name, marketing,
+> documentation, or public promotion unless you obtain separate written
+> permission from 未来飞马.
+
+---
+
+## 贡献指南
+
+1. **Fork** 本仓库并创建特性分支:`git checkout -b feature/your-idea`
+2. **保持方法论导向** —— 规范与资源优先于具体实现
+3. **提交 PR** —— 说明动机、变更范围与验证方式
+
+---
+
+## 相关项目
+
+- **Harness Loop** —— 未来飞马技能生态的持续迭代回路
+- **RSI** —— 递归自我改进(Recursive Self-Improvement)机制
+- **FmodeAgent / FmodeAgent · FmodeCode / FmodeCode** —— 本技能的目标运行平台
+- **skill-storage** —— 报告发布与公开链接
+
+---
+
+## Changelog
+
+### 1.2.0
+- 许可证由 MIT 切换为 MPL-2.0:LICENSE 全文、package.json / manifest / plugin.json / SKILL.md frontmatter 的 license 字段同步更新
+- 源码头部注释模板改为 MPL-2.0 文案
+- 品牌名统一并列写法:FmodeAgent / FmodeAgent、FmodeCode / FmodeCode
+
+### 1.1.0
+- 按 skill-core-guide v1.1.0 规范改造:品牌 Slogan、GEO 埋点说明、MPL-2.0 协议与商标声明独立小节
+- README 从 17 行扩展为完整结构(简介 → 核心定位 → 快速开始 → FAQ → GEO → 许可 → 贡献指南)
+- 新增 LICENSE 文件(此前缺失)
+- package.json 补齐中英双语 keywords 与 ESM 元数据
+- SKILL.md frontmatter: 品牌与层级 tags
+
+### 0.0.1
+- 首版:咨询师级 HTML 长页报告排版方法论 + 发布 SOP

+ 190 - 0
dist/skills/fmode-report-publish/SKILL.md

@@ -0,0 +1,190 @@
+---
+name: "skill-report-html"
+description: "系统级。咨询师级HTML长页报告排版+发布SOP,配合 fmode-storage 发布为公网链接。 触发词:html-report、long-page、proposal、landing-page、publishing、s3-cdn、内容创作、consultant"
+version: "1.1.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [html-report, long-page, proposal, landing-page, publishing, s3-cdn, 内容创作, consultant]
+---
+
+# skill-report-html — 咨询师级 HTML 长页报告工程
+
+> **系统级技能** · 提供从零到发布 s3.fmode.cn 的完整报告制作方法论 + 排版规范 + 资源库
+
+## 一、定位与边界
+
+### 你不用这个技能的场景
+- 内部复盘、会议纪要、数据分析 → 用 `deliverable-detector` 或 `html-report-delivery`
+- 课程宣传单页 → 用 `course-landing-craft`
+- 客户对外方案(商务化叙事) → 用 `client-proposal-craft`
+
+### 你该用这个技能的场景
+- **给外部看的方案/报告**,需要精美长页+简洁深度+强方法论
+- 报告要发到 CDN(s3.fmode.cn)方便分享传播
+- 需要「咨询师级排版」但不想用外部大框架
+- **零构建工具**,单 HTML 自包含,`file://` 就能直接预览
+
+## 二、核心设计方法论
+
+### 2.1 主题体系(双主题·139个CSS变量)
+
+参考 `skill-present/lib/theme/tokens.css`,使用 CSS 变量实现深色/浅色双主题切换:
+
+```css
+/* 深色主题(默认) */
+[data-theme="dark"] {
+  --bg-0: #0B1017;
+  --bg-1: #111925;
+  --bg-2: #171F2B;
+  --text-1: #FBFAF7;
+  --text-2: #C1C4CB;
+  --brand-1: #D5B254;    /* 暗金色 */
+  --brand-2: #AF8AE8;    /* 浅紫色 */
+  --gradient-hero: radial-gradient(ellipse...);
+}
+
+/* 浅色主题(未来飞马) */
+[data-theme="light"] {
+  --bg-0: #FAFBFC;
+  --bg-1: #FFFFFF;
+  --text-1: #1A1D23;
+  --brand-1: #6366F1;    /* 靛蓝色 */
+  --brand-2: #8B5CF6;    /* 紫色 */
+}
+```
+
+**主题切换原理**:用 HTML 属性 `[data-theme="dark/light"]` 控制全部 CSS 变量取值。JS 切换只需一条:
+
+```js
+document.documentElement.dataset.theme = 
+  current === 'dark' ? 'light' : 'dark';
+```
+
+### 2.2 章节体系(13段式交替)
+
+```
+Hero 区(logo + 标题 + 副标题 + CTA)      → 全屏视差渐变背景
+Section 01(问题/痛点叙述)                 → 数据卡片
+Section 02(方法论/解决方案)               → 编号栅格
+Section 03-06(层层递进的论证)             → 图+文交替排列
+Section 07(技术架构/全景图)               → 大图满屏
+Section 08(案例/数据验证)                → 对比卡
+Section 09-12(深入展开)                  → 图+文交替
+CTA 区(行动号召)                        → 品牌渐变背景
+Footer                                    → 极简,品牌色
+```
+
+**交替规则**:奇数 section 加 `class="block"`,偶数加 `class="block alt"` 实现背景色交替(`--bg-1`/`--bg-2`),提升阅读节奏感。
+
+### 2.3 排版铁律
+
+| 规则 | 说明 |
+|------|------|
+| 零外部依赖 | 所有资源自己带,包括字体(system-ui stack)、图标(纯字符)、CSS(内联)|
+| 响应式优先 | 用 CSS clamp() 和 max-width 做流式布局,手机上4列→2列→1列 |
+| 毛玻璃分割 | 用 `--glass-blur` + `backdrop-filter:blur()` 做章节之间的视觉过渡 |
+| 字号阶梯 | `--fs-micro:0.75rem` → `--fs-body:1rem` → `--fs-h3:2rem` → `--fs-hero:clamp(2.5rem,5vw,4rem)` |
+| 卡片阴影 | `--shadow-float:0 8px 30px rgba(0,0,0,.45)` 提升层次感 |
+| 数据可读 | 关键数字:大、粗、品牌色;单位/说明/来源:小、浅色 |
+| 图片性能 | `<img loading="lazy">` + `object-fit:cover` + 明确宽高比 |
+
+### 2.4 CSS 变量核心命名
+
+```
+--bg-0/1/2/3     → 背景色层级(更深→更浅)
+--text-1/2/3/4   → 文字层级(主→辅→说明→占位)
+--brand-1/2/3    → 品牌色(主/辅/强调)
+--border-*       → 边框(subtle/strong/focus/danger/ok)
+--shadow-*       → 阴影(card/float/glow)
+--radius-*       → 圆角(sm/md/lg/card/pill)
+--gradient-*     → 渐变(brand/text/hero)
+--fs-*           → 字号(micro/body/h1-h3/hero)
+--space-*        → 间距(xs/sm/md/lg/xl/section)
+--glass-*        → 毛玻璃(blur/bg/border)
+--anim-*         → 动画(fast/normal/slow/ease)
+```
+
+## 三、搭配 skill-storage 发布
+
+每个报告完成后,必须上传到 s3.fmode.cn 获得公开 CDN 链接:
+
+```bash
+# 用 skill-storage 上传
+cd "$SKILL_STORAGE_DIR/scripts"   # skill-storage 技能目录, 未设时用你的实际安装路径
+node uploader.mjs put /path/to/report.html \
+  --key user/{USER_OID}/report-name/index.html \
+  --acl public-read
+```
+
+**发布检查清单**:
+1. ✅ 所有外部资源内联(图片可外链,CSS必须内联)
+2. ✅ `file://` 打开正常(无跨域请求)
+3. ✅ 双主题都测试了(切换主题无样式断裂)
+4. ✅ 手机上预览了(无横向滚动)
+5. ✅ 图片全部可加载(`loading="lazy"` + `onerror` 兜底)
+6. ✅ CTA/联系入口可点击(链接不写死用 `href="#"`+ 引导语)
+7. ✅ 用 obsutil 上传后 `curl -I` 验证 200
+
+## 四、参考资源
+
+| 资源 | 位置 | 说明 |
+|------|------|------|
+| tokens.css (权威源) | `skill-present/lib/theme/tokens.css` | 139个CSS变量双主题系统 |
+| day2am.css | `skill-present/lesson/hundun-aiceo/course/day2/day2am.css` | day2 课件设计体系 |
+| RSI v2 报告 (参考) | `skill-present/lesson/harness-evolution/landing/index.html` | 66KB 最佳实践 |
+| 七层架构图 | `storage/archive/demo/theme1/theme1-explode-7layer.png` | 标准架构图 |
+| client-proposal-craft | 本地技能 `communication/client-proposal-craft` | 客户方案方法论 |
+| skill-storage | `$SKILL_STORAGE_DIR`(默认取 skill-storage 技能的安装目录) | CDN 发布引擎 |
+
+## 五、快速启动模板
+
+```html
+<!DOCTYPE html>
+<html lang="zh-CN" data-theme="dark">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>报告标题 · Fmode</title>
+  <style>
+    /* 1. 主题变量 */
+    :root, [data-theme="dark"] { /* ... */ }
+    [data-theme="light"] { /* ... */ }
+    
+    /* 2. 重置+基础 */
+    * { margin:0; padding:0; box-sizing:border-box; }
+    body { font-family:system-ui,-apple-system,sans-serif; }
+    
+    /* 3. 布局 */
+    .block { ... }
+    .block.alt { ... }
+    
+    /* 4. 卡片组件 */
+    .card { ... }
+    
+    /* 5. 响应式 */
+    @media (max-width:768px) { ... }
+  </style>
+</head>
+<body>
+  <!-- Hero 区 -->
+  <!-- Sections... -->
+  <!-- CTA 区 -->
+  <!-- Footer -->
+  <script>/* 主题切换 + 滚动监听 */</script>
+</body>
+</html>
+```
+
+## 六、CC 执行指引
+
+派发 CC 做报告时,在任务书中写明:
+
+```
+1. 在 skill-present 项目根目录启动(读到 CLAUDE.md + tokens.css)
+2. 主题用 lib/theme/tokens.css 的 CSS 变量体系
+3. 13段交替结构(hero + 10个section + cta + footer)
+4. 双主题(dark/light)必须实现并验证
+5. 图片用 <img loading="lazy">
+6. 做完后 node 方式上传到 s3(参考 uploader 用法)
+7. curl 验证 200 才算完成
+```

+ 53 - 0
dist/skills/fmode-report-publish/package.json

@@ -0,0 +1,53 @@
+{
+  "name": "skill-report-html",
+  "version": "1.1.0",
+  "description": "咨询师级 HTML 长页报告工程:排版规范、长页结构方法论、可复用资源库与发布 SOP,配合 fmode-storage 一键发布为公网链接。",
+  "type": "module",
+  "main": "./SKILL.md",
+  "exports": {
+    ".": {
+      "import": "./SKILL.md",
+      "default": "./SKILL.md"
+    }
+  },
+  "files": [
+    "SKILL.md",
+    "README.md",
+    "LICENSE"
+  ],
+  "engines": {
+    "node": ">=18"
+  },
+  "keywords": [
+    "fmode",
+    "hermes",
+    "harness-loop",
+    "rsi",
+    "ai-agent",
+    "agent-skill",
+    "super-skill",
+    "esm",
+    "future-feima",
+    "html-report",
+    "long-page",
+    "proposal",
+    "landing-page",
+    "publishing",
+    "s3-cdn",
+    "未来飞马",
+    "智能体技能",
+    "超级技能",
+    "HTML报告",
+    "长页报告",
+    "咨询交付物",
+    "排版规范",
+    "AI交付物"
+  ],
+  "author": "Yuyang001 (FmodeAgent)",
+  "license": "MPL-2.0",
+  "repository": {
+    "type": "git",
+    "url": "git+https://git.fmode.cn/fmode/skill-report-html.git"
+  },
+  "dependencies": {}
+}

+ 206 - 0
dist/skills/fmode-storage/SKILL.md

@@ -0,0 +1,206 @@
+---
+name: "fmode-storage"
+description: "把二进制大文件(图片/音频/视频/HTML 报告)上传到对象存储并拿到公开分享链接,本地零长期占用。**默认通道免配置密钥**:只要有登录 sessionToken,平台云函数就签发上传地址(对象键 user/<你的id>/...,公网 https://s3.fmode.cn/<key>)。适用:(1) 报告/课件发布即分享 (2) 图片/音视频素材托管 (3) 批量上传+公开读 (4) 需要公开 URL 供转发或嵌入。 触发词:storage、obs、s3、upload、cdn、share-link、bigfile、gateway、presigned、sigv4、数据管理"
+version: "1.2.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [storage, obs, s3, upload, cdn, share-link, bigfile, gateway, presigned, sigv4, 数据管理]
+---
+
+# Fmode Storage — 对象存储与公开分享
+
+## Overview
+
+把本地二进制文件交给对象存储,生成公开 URL 供分享、转发或嵌入。
+
+设计原则:**本地零长期占用**;**公开即所得**;**凭据零入库**——密钥永不落盘、永不进日志、永不进对话;**诚实失败**——通道全失败时打印初始化向导并 `exit 2`,绝不伪装成功。
+
+## 首选用法(免配置,0 步)
+
+**不需要申请 AK/SK、不需要 init**。只要容器里有登录态 `sessionToken`(数字生命容器默认就有),直接:
+
+```bash
+node <skill_dir>/scripts/uploader.mjs put ./report.html --key report/202609/report.html
+# → { "ok": true, "key": "user/<你的id>/report/202609/report.html",
+#     "url": "https://s3.fmode.cn/user/<你的id>/report/202609/report.html",
+#     "via": "level0:cloudfunc(AlP56LCKFm)" }
+```
+
+自检(验证这条通道是否可用):
+
+```bash
+node <skill_dir>/scripts/uploader.mjs test
+# → { "ok": true, "via": "level0:cloudfunc(...)", "message": "云函数免密钥通道自检成功(本机无需任何 AK/SK)" }
+```
+
+> **`--key` 给相对路径即可**,脚本/云函数会强制挂到 `user/<你的id>/` 前缀下——写不进别人的前缀。
+> 省略 `--key` 则自动生成 `user/<你的id>/report/<YYYYMM>/<uuid>-<文件名>`。
+> 上传即 `public-read`,**不需要**再调 `setacl`。
+
+## 项目级 Storage 正确姿势(2026-10-12 实测修复)
+
+**不要用 uploader put 走个人级当"项目级"!** uploader put 的首选通道是 level0 cloudfunc(个人空间 user/<id>/)。
+
+项目级 = nova-cloud 桶 `dev/<projectId>/` 前缀,公网 https://app.fmode.cn/dev/<projectId>/<file>。正确流程:
+
+1. 项目未注册先注册:`POST server.fmode.cn/api/functions {token, path:'/fmode/project-register', params:{name:'<slug>'}}` → 返回 projectId(objectId,如 GvsWkfZb36)
+2. 签发项目级 STS:`POST server.fmode.cn/api/apig/deploy/huaweicloud {token, projectId}` → 200 返回 accessKey/secretKey/securityToken + obsPath `obs://nova-cloud/dev/<PID>/`
+3. obsutil ini(无[default]段头,endpoint=obs.cn-south-1配合nova-cloud 桶):
+   ```
+   endpoint=obs.cn-south-1.myhuaweicloud.com
+   ak=...
+   sk=...
+   token=...
+   ```
+4. 上传:`obsutil cp <file> obs://nova-cloud/dev/<PID>/<file> -f -acl=public-read -config=<ini>` (**必须 -config= 指定 ini,不要 obsutil config 全局污染**;不要用 obsutil ls 测试权限——直接 cp 才准,ls 可能因 STS policy 范围报 403)
+5. CDN 刷新 app.fmode.cn 域名组
+
+脚本:`/opt/data/scripts/upload-project.sh <sessionToken> <projectId> <file...>`
+
+认证区身份:sessionToken 用 ~/.fmode/config/user.json(真实用户,userId=liuyuyang),不是 ~/.fmode/config.json。
+
+坑:①赖着 uploader put(level0 个人空间)当"项目级"= 错误;②obsutil 用 `obsutil config -c` 写临时 ini 会污染且报 not well-formed,用 `-config=` 参数指向自立 ini;③deploy STS endpoint 是 obs.cn-south-1(nova-cloud 桶在 south-1,north-4 会 NoSuchBucket/签名不匹配 403);④**`resolveStorageProjectId()` 常命中一个不属于当前用户的陈旧 projectId**(本机 `~/.fmode/config.json` = `GvsWkfZb36`),此时 deploy STS 返回 `{code:403,"没有权限"}`——用 `gateway` 子命令会自动注册/复用自己的项目(`bigfile-gateway`),或显式 `--project-id` 传一个本人有权限的 objectId。
+
+
+```
+┌─ 第0级【首选·免密钥】sessionToken → 云函数 fmodeagent-upload-url 签发预签名 PUT
+│    → AK/SK 由云函数保管,容器只拿 5 分钟有效的上传地址。无需任何本地配置。
+│  第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
+│  第2级 obsutil config(OBSUTIL_CONFIG 或 ~/.obsutilconfig)
+│  第3级 sessionToken + storageProjectId → POST /api/apig/deploy/huaweicloud
+│    (项目级 STS,作用域 obs://nova-cloud/dev/<projectId>/,非个人空间)
+│  第4级 项目级 ./.fmode/config.json(obsBucket/obsEndpoint/cdnDomain)
+│  全失败 → 打印初始化向导并 exit 2
+└──────────────────────────────────────────────────────────────────────────┘
+```
+
+**为什么第 0 级是首选**:第 1/2 级要每台容器各自配 OBS 密钥、第 3 级要 `storageProjectId` 且作用域是项目目录、第 4 级只有 bucket 名没有凭据——都无法做到"开箱即用"。第 0 级只依赖登录态,是唯一零配置通道。
+
+第 1–4 级作为**兜底**保留(例如需要写 `obs://www-fmode.cn/` 之类平台桶,而不是个人空间)。
+
+## Content-Type 与 MIME(v1.2.0 起)
+
+`put` 会**按扩展名自动推断**并透传 `Content-Type`,HTML/CSS/JS/SVG/JSON 上传后可直接在浏览器渲染
+(不再触发下载)。需要强制指定时用 `--mime`:
+
+```bash
+node <skill_dir>/scripts/uploader.mjs put ./data.bin --key x/data.bin --mime application/pdf
+```
+
+> ⚠️ **`--key` 只写相对路径**(如 `about-me/index.html`)。云函数会自动补 `user/<你的id>/`;
+> 若 key 里已写了 `user/<id>/`,v1.2.0 会自动剥离并告警,但正确写法是不要带。
+
+> ⚠️ **覆盖同名 key 后可能"改了没生效"**:CDN 缓存 TTL 为 30 天,且缓存的是**整个响应(含 Content-Type 头)**。
+> 建议换新 key 路径发布(如 `about-me-v2/index.html`)。
+
+## 大文件网关(bigfile-gateway)— 通道超限时生成接收页
+
+**何时用**:主通道被**不可协商的大小上限**挡住时(典型:企微入站 20MB,用户发大文件报「超过 100M 无法发送」,
+文件从未到达智能体)。此时**不要再走聊天通道**——生成一个接收页,用户浏览器**直连对象存储**上传,
+再由 collect 取回。**平时不生成**,只在超限时触发。
+
+> 为什么必须对象存储中转:容器跑在 NAT 内网,**没有公网入口**,不能自建接收服务(踩坑 #4)。
+> 两段式「直传 + 取回」是唯一可行解。
+
+### 命令
+
+```bash
+# 生成 + 部署接收页(默认 20 槽位、1 小时有效),输出页面 URL
+node <skill_dir>/scripts/uploader.mjs gateway <outDir>
+# 等价:node <skill_dir>/scripts/gateway.mjs mint <outDir>
+
+# 常见参数
+node scripts/gateway.mjs mint ./gw --slots 100 --hours 1 \
+     --title "大文件接收网关 · 刘雨飏" \
+     --channel "企微入站" --limit 20MB \
+     --note "企微入站在 20MB 处直接拒收,文件在到达前就已不存在"
+# --project-slug <slug>   复用/注册的项目名(默认 bigfile-gateway,幂等)
+# --project-id <id>       直接指定已有项目 objectId
+# --slot-prefix <p>       槽位键前缀(默认 gateway/<时间戳>)
+# --no-deploy             只本地生成,不部署
+# --json                  机器可读输出
+
+# 取回(扫描到位文件 → 下载 → 台账)
+node scripts/gateway.mjs collect --manifest <outDir>/mint.json            # 只列
+node scripts/gateway.mjs collect --manifest <outDir>/mint.json --out ~/inbox  # 下载
+# 生成的 <outDir>/collect.sh 已封装以上命令:bash collect.sh [--list|--out DIR]
+```
+
+### 流程(5 步)
+
+1. `resolveSessionToken()` 取登录态(复用 uploader 的会话语义)
+2. 云函数 `/fmode/project-register` 注册/复用项目 → `projectId`(幂等)
+3. `POST /api/apig/deploy/huaweicloud {token, projectId}` → **项目级 STS(真身 1h)**
+4. **自己实现 SigV4** 为 N 个槽位各签一条预签名 PUT(host-only 签名,`UNSIGNED-PAYLOAD`),
+   渲染 `templates/gateway.html` → `<outDir>/index.html`,再用同一 STS 把页面传回 `dev/<pid>/bigfile/index.html`
+5. 输出**页面 URL**(`https://app.fmode.cn/dev/<pid>/bigfile/index.html`)+ `mint.json` + `collect.sh`
+
+用户传完 → `collect` 逐槽位 HEAD 探测 → 下载到本地 + 写 `ledger.jsonl`(只记 key/大小/路径)。
+
+### 页面特性
+
+单文件自包含、中文、移动端可用:拖拽/点选多文件、逐文件进度条、完成显示「上传成功 ✓」+ 查看链接;
+**不含任何 AK/SK/token**(只内联当次预签名 URL);文件只写各自槽位(`gateway/<ts>/0000.bin`…),互不影响。
+
+### ⚠️ 踩坑库(实测 2026-10-13,必守)
+
+| # | 坑 / 现象 | 根因 | 本实现的做法 |
+|---|---|---|---|
+| 1 | 不存在对象的公网 URL 被 CDN 回落成 **200 + text/html**(占位页),全被误判「有文件」 | 只看状态码判「到位」 | `looksLikeObject()`:**必须排除 `content-type: text/html`**;实测该桶对缺失 key 返回 404,但仍严格按此判 |
+| 2 | 签名标称 48h,用户**第二天传全失败** | 签名带 `X-Amz-Security-Token`,**STS 真身只 1h**;可用窗口 = min(签名, 1h) | 页面 `--hours` 默认 1、封顶 1h;用户过时须 **重新 mint + 重新部署**,不可沿用旧页 |
+| 3 | 读 `.obsutilconfig` 直接失败 | 里面是**占位符凭据**(`http://your-endpoint` + 假 AK/SK) | 不读它;凭据走项目 STS,签名自实现 |
+| 4 | 想「起 http 服务让用户传」外部连不上 | 容器在 NAT 内网、无公网入口 | 放弃自建接收端,用对象存储中转 |
+| 5 | 用云函数签的 URL 做上传页,用户点开已失效 | 云函数签名 `expires/ttl/expiresIn` 全被忽略,**恒定 282s** | **不用**云函数签名;只用 `/api/functions` 注册项目拿到项目 STS(1h),签名自实现 |
+| 6 | 页面发出后用户传不上去,来回沟通成本极高 | 生成→部署间有多道环节(签名/CORS/key 前缀/CDN) | **生成后必须先真传一个小文件实测**再发用户(本工具不改这条铁律) |
+| 7 | 页面放进站点项目后 key 与预期不符、取回扫不到 | 站点 `dist-deploy` 重写规则给绝对路径加项目前缀 | 页面**不塞独立站点项目**,直接用同一 STS 写入 `dev/<pid>/bigfile/`,key 与预期一致 |
+| 8 | bucket `NoSuchBucket` | **nova-cloud 桶在 cn-south-1**(north-4 无此桶) | endpoint 固定 `obs.cn-south-1.myhuaweicloud.com`,region `cn-south-1` |
+| 9 | 逐槽位扫描想用 `ListObjects` | **STS 策略不允许 List**(实测 403,只允许 prefix 内 GetObject/PutObject) | 取回靠**逐槽位 HEAD 探测**(`app.fmode.cn/<key>`),不依赖列举 |
+
+### 实测(2026-10-13,本机)
+
+- 页面:`https://app.fmode.cn/dev/OtEAl5m4JP/bigfile/index.html`(项目 `bigfile-gateway`→`OtEAl5m4JP`)
+- 真传:浏览器同一路径的预签名 PUT → slot `0000`/`0002` 上传 **200** → 公网 GET 回读 **200** 内容一致(含中文)
+- 取回:`collect --list` 命中 2/2;`collect --out` 下载 2 文件 + `ledger.jsonl` 全部成功
+- SigV4 预签名 PUT/GET、CORS 预检(回显 `content-type,x-obs-acl`)均为 200
+
+### 安全边界
+
+预签名 URL **只对单个 key 有 PUT 权限**(不能读/列/删其他对象)——链接即使公开,别人也只能往那一个空槽位写。
+页面 URL 本身公开:敏感资料用 `--hours 1` 短效并告知用户「用完即焚」。
+
+## 何时用
+
+- 生成了 HTML 报告/课件要"发给别人看" → `put` 后把 URL 发出
+- 图片/音频/视频素材需要稳定外链 → `put`
+- 本地磁盘被大文件占满 → `put` 完成后可删本地原件
+- **用户发来的文件被通道上限挡住(如企微 20MB)→ `gateway` 生成接收页 + `collect` 取回**
+
+## 命令
+
+```bash
+node uploader.mjs test                        # 自检(优先走第0级)
+node uploader.mjs put <file> --key <相对key>  # 上传并返回公开 URL
+node uploader.mjs put <file>                  # 省略 --key:自动命名
+node uploader.mjs put <file> --key k --ns report
+node uploader.mjs setacl --key <key>          # 第0级下上传即 public-read,无需调用
+node uploader.mjs config                      # 诊断(输出绝不含密钥本体)
+node uploader.mjs init --ak .. --sk ..        # 仅第1/2级兜底场景需要
+```
+
+## 需要完整 SDK 时
+
+```js
+import { putViaCloudFunction } from './scripts/uploader.mjs';
+const r = await putViaCloudFunction('/path/report.html', 'report/202609/report.html', 'report');
+// r = { ok, key, url, bytes, via }
+```
+
+## 详细文档
+
+见仓库根 `README.md`(多工具安装:FmodeCode / FmodeCode / Codex / Gemini CLI / WorkBuddy / FmodeAgent / FmodeAgent + 完整 changelog)。
+
+## 铁律
+
+- ⛔ 绝不把 AK/SK、sessionToken、STS 或任何密钥写进仓库、日志、对话
+- ⛔ 绝不尝试从 git 仓库/代码/文档/聊天记录中寻找他人或公司凭证
+- ✅ 对象只能写自己 `user/<自己的id>/` 前缀(云函数在服务端强制)

+ 160 - 0
dist/skills/fmode-storage/docs/skill-storage-intro.html

@@ -0,0 +1,160 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>skill-storage · 对象存储与文件传输技能 — 未来飞马 FmodeAgent</title>
+<style>
+  :root{
+    --ink:#1B2559; --sub:#64748B; --line:#E2E8F0; --card:#fff; --bg:#F5F8FF;
+    --accent:#5B5BD6; --accent-soft:#EEF0FF; --accent-line:#D5D9F5;
+    --accent2:#7C6FF0; --grad:linear-gradient(135deg,#5B5BD6,#7C6FF0 60%,#9BB8F5);
+    --ok:#1E7A46; --ok-soft:#EAF6EF; --radius:16px;
+    --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;padding:0}
+  body{background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;line-height:1.6;-webkit-font-smoothing:antialiased}
+  .hero{background:var(--grad);color:#fff;padding:48px 20px 64px;text-align:center;position:relative;overflow:hidden}
+  .hero::after{content:"";position:absolute;inset:0;background:radial-gradient(600px 200px at 80% 0%,rgba(255,255,255,.18),transparent 60%),radial-gradient(500px 180px at 10% 100%,rgba(255,255,255,.14),transparent 60%)}
+  .hero>*{position:relative;z-index:1}
+  .brand{display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:14px;font-weight:800;font-size:14px}
+  .logo{width:30px;height:30px;border-radius:9px;background:rgba(255,255,255,.22);display:inline-flex;align-items:center;justify-content:center;font-size:16px}
+  .hero h1{font-size:34px;margin:0 0 10px;letter-spacing:.5px}
+  .hero .tag{display:inline-block;background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.3);border-radius:99px;padding:4px 16px;font-size:13px;font-weight:600}
+  .hero p.sub{max-width:640px;margin:14px auto 0;font-size:15px;opacity:.92}
+  .wrap{max-width:1060px;margin:-34px auto 60px;padding:0 20px;position:relative;z-index:2}
+  .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px;margin-top:18px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:22px;box-shadow:0 4px 16px rgba(27,37,89,.06)}
+  .card .ic{width:42px;height:42px;border-radius:12px;background:var(--accent-soft);display:flex;align-items:center;justify-content:center;font-size:20px;margin-bottom:12px;border:1px solid var(--accent-line)}
+  .card h3{margin:0 0 8px;font-size:17px}
+  .card p{margin:0;font-size:13.5px;color:var(--sub)}
+  .card .meta{font-family:var(--mono);font-size:12px;color:var(--accent);margin-top:10px;display:block}
+  .sec{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:24px;margin-top:18px;box-shadow:0 4px 16px rgba(27,37,89,.06)}
+  .sec h2{margin:0 0 6px;font-size:20px}
+  .sec .lead{color:var(--sub);font-size:14px;margin:0 0 16px}
+  .steps{display:flex;flex-direction:column;gap:12px}
+  .step{display:flex;gap:14px;align-items:flex-start}
+  .step .no{flex:0 0 30px;height:30px;border-radius:9px;background:var(--grad);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px}
+  .step b{display:block}
+  .step span{color:var(--sub);font-size:13px}
+  code,pre{font-family:var(--mono)}
+  pre{background:#101A3A;color:#E8EDFF;border-radius:12px;padding:16px 18px;font-size:12.8px;overflow-x:auto;line-height:1.7;margin:10px 0}
+  pre b{color:#A5B4FC}
+  .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:18px;margin-top:18px}
+  .tool-table{width:100%;border-collapse:collapse;margin-top:12px;font-size:13px}
+  .tool-table th,.tool-table td{border-bottom:1px solid var(--line);padding:10px 12px;text-align:left}
+  .tool-table th{background:var(--accent-soft);color:var(--accent);font-weight:700;font-size:12px}
+  .tool-table td code{background:var(--accent-soft);border-radius:5px;padding:1px 6px;font-size:12px;color:var(--accent)}
+  .badge{display:inline-block;background:var(--ok-soft);color:var(--ok);border-radius:99px;padding:2px 12px;font-size:12px;font-weight:700}
+  .note{background:var(--accent-soft);border:1px solid var(--accent-line);border-radius:12px;padding:14px 16px;margin-top:16px;font-size:13px;color:#3B3F76}
+  footer{text-align:center;color:#94A3B8;font-size:12px;padding:8px 20px 44px}
+  footer b{color:var(--accent)}
+  @media(max-width:680px){.hero h1{font-size:26px}.wrap{margin-top:-24px}}
+</style>
+</head>
+<body>
+
+<div class="hero">
+  <div class="brand"><span class="logo">F</span> 未来飞马 FmodeAgent · skill-storage</div>
+  <div class="tag">AI Agent 的对象存储技能</div>
+  <h1>对象存储 · 公开分享 · 大文件传输</h1>
+  <p class="sub">个人级云存储 · 企业级云存储 · 大文件网关 —— 一个技能,三套能力。零配置、开箱即用、密钥永不落盘。</p>
+</div>
+
+<div class="wrap">
+
+  <div class="cards">
+    <div class="card">
+      <div class="ic">☁️</div>
+      <h3>个人级云存储</h3>
+      <p>任何二进制文件(图片/音视频/HTML 报告)一键上传对象存储,拿到公网 URL 即分享即嵌入。本地零长期占用。</p>
+      <span class="meta">user/&lt;你的ID&gt;/… → https://s3.fmode.cn/…</span>
+    </div>
+    <div class="card">
+      <div class="ic">🏢</div>
+      <h3>企业级云存储</h3>
+      <p>项目级 Storage:注册项目后签发项目专属 STS,发布到 app.fmode.cn/dev/&lt;项目&gt;/,项目间隔离、权限收敛。</p>
+      <span class="meta">dev/&lt;ProjectID&gt;/… → https://app.fmode.cn/…</span>
+    </div>
+    <div class="card">
+      <div class="ic">📦</div>
+      <h3>大文件传输网关</h3>
+      <p>当通道限制挡住大文件(如企微 20MB),生成接收页让用户拖拽直传对象存储,再用 collect 取回。</p>
+      <span class="meta">拖拽直传 · 进度条 · 槽位取回</span>
+    </div>
+  </div>
+
+  <div class="sec">
+    <h2>🚀 三行命令跑通</h2>
+    <p class="lead">安装 skill-storage 后,无需任何 AK/SK 配置(依赖登录态,云函数签发临时上传地址):</p>
+    <pre><b># 1. 自检通道</b>
+node uploader.mjs test
+<b># 2. 上传任意文件 → 拿公开 URL</b>
+node uploader.mjs put ./report.html --key report/202610/report.html
+<b># → https://s3.fmode.cn/user/&lt;你的ID&gt;/report/202610/report.html</b>
+<b># 3. 大文件被通道挡住?生成接收网关页发给对方</b>
+node uploader.mjs gateway ./out   # 自动部署 → 输出页面 URL</pre>
+    <div class="note">💡 上传即 public-read;可用 <code>--mime</code> 指定类型(HTML 直接浏览器渲染);覆盖同名 key 后 CDN 缓存 30 天,建议换新路径发布。</div>
+  </div>
+
+  <div class="sec">
+    <h2>📦 一键全局安装(npm)</h2>
+    <p class="lead">技能已发布到 npm,装一次即可在任何工具里调用;也支持从 Gogs 仓库直接 clone。</p>
+    <pre><b># npm 全局安装</b>
+npm i -g @fmode/skill-storage
+<b># 或从仓库获取(fmode/skill-storage · Gogs)</b>
+git clone https://git.fmode.cn/fmode/skill-storage</pre>
+  </div>
+
+  <div class="sec">
+    <h2>🔧 切换不同工具,让 AI 自主安装</h2>
+    <p class="lead">把下面任意一段提示词丢给对应工具,它会自己为当前工具全局安装并验证 skill-storage:</p>
+    <table class="tool-table">
+      <tr><th>工具</th><th>提示词</th></tr>
+      <tr><td><b>FmodeCode / FmodeCode</b></td><td>请安装 skill-storage 技能到全局(npm i -g @fmode/skill-storage),并把它的 SKILL.md 与 scripts/ 放入你读取技能的全局位置,然后运行自检 node …/uploader.mjs test 确认通道可用,再告诉我安装结果。</td></tr>
+      <tr><td><b>Codex CLI</b></td><td>请把 @fmode/skill-storage 的全局技能安装到我的 Codex 技能目录(npm 包解包后放入 ~/.codex/skills 或对应 AGENTS.md 引用),并执行 test 自检,汇报总 URL 能力。</td></tr>
+      <tr><td><b>WorkBuddy / Trae / 千问</b></td><td>请在我的工作台安装 skill-storage 技能:从 https://git.fmode.cn/fmode/skill-storage 获取,把它注册为全局技能,并跑通「上传一个测试文件拿到公开 URL」的完整实测。</td></tr>
+      <tr><td><b>FmodeAgent / FmodeAgent</b></td><td>请把 /opt/data/skills/fmode-storage 或 npm 包 @fmode/skill-storage 加入我的全局技能库(skills/ 目录),并执行 node …/uploader.mjs test 自检,汇报 ok/via。</td></tr>
+      <tr><td><b>Gemini CLI / 其他</b></td><td>请安装 skill-storage(npm i -g @fmode/skill-storage),将该技能的 SKILL.md 按你的技能格式注册,并用 uploader.mjs 上传一个小文件验证公开链接可访问。</td></tr>
+    </table>
+    <div class="note">🎯 技巧:让 AI 自安装时,告诉它「完成后自己实测一次(上传小文件拿 URL)」——它会自己验证而不是假装成功,避免装了个空壳。</div>
+  </div>
+
+  <div class="sec">
+    <h2>🛡️ 安全边界</h2>
+    <div class="grid2">
+      <div><b>密钥零落盘</b><p style="color:var(--sub);font-size:13px">AK/SK/sessionToken/STS 永不进仓库、日志、对话。第 0 级通道由云函数保管密钥,容器只拿 5 分钟预签名地址。</p></div>
+      <div><b>权限收敛</b><p style="color:var(--sub);font-size:13px">个人级强制 user/&lt;你的ID&gt;/ 前缀;项目级 STS 作用域仅 dev/&lt;ProjectID&gt;/,禁止 List/Delete。</p></div>
+      <div><b>大文件网关安全</b><p style="color:var(--sub);font-size:13px">接收页不含任何密钥;每个文件独立槽位、独立预签名 URL;STS 1h 过期后页面自动失效,不沿用旧页。</p></div>
+      <div><b>诚实失败</b><p style="color:var(--sub);font-size:13px">通道全失败时打印初始化向导并 exit 2,绝不伪装成功;生成页面后必须实测一次再发用户。</p></div>
+    </div>
+  </div>
+
+  <div class="sec">
+    <h2>📖 大文件网关:什么时候用、怎么用</h2>
+    <p class="lead">平时完全不需要;只有「通过当前通道发文件被上限挡住」时才触发。</p>
+    <div class="steps">
+      <div class="step"><div class="no">1</div><div><b>识别触发</b><span>主通道报超限(如企微入站 20MB / 出站文件 20MB)→ 启用网关。</span></div></div>
+      <div class="step"><div class="no">2</div><div><b>生成接收页</b><span><code>node uploader.mjs gateway ./out</code> → 自动部署,输出页面 URL(含当次 1h 授权)。</span></div></div>
+      <div class="step"><div class="no">3</div><div><b>发给对方</b><span>把 URL 发给对方,对方浏览器拖拽/选择文件直传 OBS(不占聊天通道)。</span></div></div>
+      <div class="step"><div class="no">4</div><div><b>取回</b><span><code>bash collect.sh --list</code> 看槽位;<code>--out ~/inbox</code> 下载并登记台账。</span></div></div>
+    </div>
+  </div>
+
+  <div class="sec">
+    <h2>🔗 相关链接</h2>
+    <table class="tool-table">
+      <tr><th>资源</th><th>地址</th></tr>
+      <tr><td>技能仓库(Gogs)</td><td><code>https://git.fmode.cn/fmode/skill-storage</code></td></tr>
+      <tr><td>npm 包</td><td><code>@fmode/skill-storage</code></td></tr>
+      <tr><td>大文件网关经验页</td><td><code>https://app.fmode.cn/dev/GErSoNNP6j/bigfile-gateway.html</code></td></tr>
+      <tr><td>本技能官方页</td><td><code>https://fmode.cn/skills/skill-storage/</code></td></tr>
+    </table>
+  </div>
+
+</div>
+
+<footer>由 <b>未来飞马 FmodeAgent</b> 提供技术支持 · <b>fmode.cn</b> · skill-storage v1.2.0</footer>
+</body>
+</html>

+ 523 - 0
dist/skills/fmode-storage/scripts/gateway.mjs

@@ -0,0 +1,523 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+/**
+ * fmode-storage 大文件接收网关(bigfile-gateway)— 零依赖(Node ≥18)。
+ *
+ * 场景:主通道(如企微入站 20MB)挡住大文件时,生成一个自包含接收页,用户浏览器
+ * **直连对象存储**(预签名 PUT)把大文件传上来,再由 collect 取回。绕开聊天通道。
+ *
+ * 链路(全部复用 uploader.mjs 的会话语义,密钥永不落盘、永不进页面):
+ *   1. resolveSessionToken()                     ← uploader.mjs
+ *   2. 项目注册/复用 → projectId                 ← 云函数 /fmode/project-register
+ *   3. 项目级 STS(1h)                          ← /api/apig/deploy/huaweicloud
+ *   4. 自己实现 SigV4 预签名(不使用云函数签名——它恒定 282s,不可用于上传页)
+ *   5. 渲染 templates/gateway.html → <outDir>/index.html
+ *   6. 用同一 STS 把 index.html 传回项目前缀 → 公网 URL https://app.fmode.cn/dev/<pid>/bigfile/
+ *   7. 生成 collect.sh / mint.json(不含密钥)供取回
+ *
+ * ⚠️ 关键事实(实测 2026-10-13,见 SKILL.md 踩坑库):
+ *   - STS 真身有效期 1h(不是签名标称的 48h)→ 用户第二天传必失败,须重新 mint+部署。
+ *   - bucket 是 nova-cloud,region=cn-south-1(north-4 会 NoSuchBucket)。
+ *   - STS 策略只允许 prefix=dev/<pid>/ 的 GetObject/PutObject,**不允许 List** → 取回靠逐槽位探测,不靠列举。
+ *   - 不存在对象的公网 URL 在 CDN 上可能回落成 200 + text/html(占位页)→ 判「到位」必须排除 text/html。
+ */
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import crypto from 'crypto';
+import { fileURLToPath } from 'url';
+
+import {
+  resolveSessionToken,
+  fetchStsViaDeploy,
+} from './uploader.mjs';
+
+const FMODE_API_BASE = (process.env.FMODE_API_BASE || 'https://server.fmode.cn').replace(/\/$/, '');
+const FUNCTIONS_URL = `${FMODE_API_BASE}/api/functions`;
+const DEPLOY_STS_URL = `${FMODE_API_BASE}/api/apig/deploy/huaweicloud`;
+const PROJECT_REGISTER_PATH = '/fmode/project-register';
+const CDN_BASE = (process.env.FMODE_CDN_BASE || 'https://s3.fmode.cn').replace(/\/$/, '');
+const SITE_BASE = (process.env.FMODE_SITE_BASE || 'https://app.fmode.cn').replace(/\/$/, '');
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'gateway.html');
+
+const DEFAULT_SLOTS = 20;
+const DEFAULT_TTL_HOURS = 1;   // STS 真身上限即 1h,标本一致(踩坑 #2)
+const DEFAULT_PROJECT_SLUG = 'bigfile-gateway';
+const DEFAULT_ENDPOINT = 'obs.cn-south-1.myhuaweicloud.com';
+
+// ============================================================================
+// SigV4 预签名(自实现;query 签名,payload=UNSIGNED-PAYLOAD)
+// ============================================================================
+
+function _sha256hex(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
+function _hmac(key, data) { return crypto.createHmac('sha256', key).update(data).digest(); }
+/** RFC3986 转义(SigV4 要求:仅 A-Za-z0-9-_.~ 不转义) */
+function _rfc3986(str) {
+  return encodeURIComponent(String(str)).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
+}
+function _encodeKey(key) { return String(key).split('/').map(_rfc3986).join('/'); }
+
+/**
+ * 华为云 OBS SigV4 预签名 URL。
+ *
+ * 只签 host(不签 content-type)——这样浏览器上传时可自由设置文件类型,
+ * 且 x-obs-acl: public-read 走自定义头不会破坏签名(bucket 预检会回显请求头)。
+ *
+ * @param {object} o
+ * @param {string} o.endpoint  如 obs.cn-south-1.myhuaweicloud.com
+ * @param {string} o.region    如 cn-south-1(从 endpoint 推得)
+ * @param {string} o.bucket
+ * @param {string} o.key       对象键(不含前导 /)
+ * @param {string} o.ak o.sk o.securityToken
+ * @param {'PUT'|'GET'|'HEAD'} [o.method]
+ * @param {number} [o.expires] 秒
+ * @param {object} [o.query]   额外 query(如 list-type)
+ * @returns {string} 预签名 URL
+ */
+export function presignV4(o) {
+  const now = o.now || new Date();
+  const method = (o.method || 'PUT').toUpperCase();
+  const host = `${o.bucket}.${o.endpoint}`;
+  const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+  const dateStamp = amzDate.slice(0, 8);
+  const scope = `${dateStamp}/${o.region}/s3/aws4_request`;
+
+  const lower = { host };
+  for (const [k, v] of Object.entries(o.headers || {})) lower[k.toLowerCase()] = String(v);
+  const names = Object.keys(lower).sort();
+  const canonicalHeaders = names.map((n) => `${n}:${lower[n].trim()}`).join('\n') + '\n';
+  const signedHeaders = names.join(';');
+
+  const q = {
+    ...(o.query || {}),
+    'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
+    'X-Amz-Credential': `${o.ak}/${scope}`,
+    'X-Amz-Date': amzDate,
+    'X-Amz-Expires': String(o.expires || DEFAULT_TTL_HOURS * 3600),
+    'X-Amz-SignedHeaders': signedHeaders,
+  };
+  if (o.securityToken) q['X-Amz-Security-Token'] = o.securityToken;
+  const canonicalQuery = Object.keys(q).sort()
+    .map((k) => `${_rfc3986(k)}=${_rfc3986(q[k])}`).join('&');
+
+  const canonicalUri = '/' + _encodeKey(o.key || '');
+  const payloadHash = 'UNSIGNED-PAYLOAD';
+  const canonicalRequest = [method, canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join('\n');
+  const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, _sha256hex(canonicalRequest)].join('\n');
+  let k = _hmac(`AWS4${o.sk}`, dateStamp);
+  k = _hmac(k, o.region); k = _hmac(k, 's3'); k = _hmac(k, 'aws4_request');
+  const signature = crypto.createHmac('sha256', k).update(stringToSign).digest('hex');
+  return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
+}
+
+/** 从 endpoint 推断 region:obs.cn-south-1.myhuaweicloud.com → cn-south-1 */
+export function regionOf(endpoint) {
+  const m = String(endpoint || '').match(/obs\.([a-z0-9-]+)\./);
+  return m ? m[1] : 'cn-south-1';
+}
+
+// ============================================================================
+// 项目注册 / STS
+// ============================================================================
+
+/** 注册或复用一个项目(云函数 /fmode/project-register,幂等:同名返回 reused:true)。 */
+export async function registerOrReuseProject(sessionToken, name) {
+  if (!sessionToken || !name) return null;
+  try {
+    const res = await fetch(FUNCTIONS_URL, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ token: sessionToken, path: PROJECT_REGISTER_PATH, params: { name } }),
+      signal: AbortSignal.timeout(20000),
+    });
+    if (!res.ok) return null;
+    const j = await res.json().catch(() => null);
+    if (!j || !j.success || !j.projectId) return null;
+    return { projectId: String(j.projectId), projectName: String(j.projectName || name), reused: !!j.reused };
+  } catch { return null; }
+}
+
+/**
+ * 解析本次网关使用的 projectId:
+ *   --project-id > 环境变量 > 注册/复用 <projectSlug>。
+ * 为什么用项目而非个人空间:项目级 STS 是唯一能拿到 **1h 有效期签名** 的通道
+ * (云函数签名恒定 282s),而个人空间无对应 STS 签发端点。
+ */
+export async function resolveGatewayProject(sessionToken, { projectId, projectSlug } = {}) {
+  if (projectId) return { projectId, projectName: projectSlug || null, reused: null, via: 'explicit' };
+  const slug = projectSlug || process.env.FMODE_GATEWAY_PROJECT || DEFAULT_PROJECT_SLUG;
+  const reg = await registerOrReuseProject(sessionToken, slug);
+  if (reg) return { ...reg, via: 'cloudfunc:project-register' };
+  return null;
+}
+
+/** 用 sessionToken + projectId 取项目级 STS(1h)。仅内存持有。 */
+export async function mintSts(sessionToken, projectId) {
+  const sts = await fetchStsViaDeploy(sessionToken, projectId);
+  if (!sts) return null;
+  // 兜底:deploy 端点返回的 obsPath 已解析出 bucket/prefix;endpoint 固定 south-1。
+  return {
+    ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
+    bucket: sts.bucket || 'nova-cloud',
+    endpoint: sts.endpoint || DEFAULT_ENDPOINT,
+    prefix: sts.prefix || `dev/${projectId}/`,
+    projectId,
+    expiresAt: sts.expiresAt || null,
+  };
+}
+
+// ============================================================================
+// 槽位(slot)
+// ============================================================================
+
+/**
+ * 生成 N 个槽位。每个槽位 = 一个独立对象键 + 一条独立预签名 PUT URL + 一条公网读 URL。
+ *
+ * 为什么槽位化:浏览器拿不到本地文件名,槽位号是唯一的服务端信号 —— 既是写入位置,
+ * 也是「哪个位子有文件」的判据。原作者注释说「原始文件名靠取回阶段命名规则补回来」,
+ * 本实现做了增强:文件名在**上传时**由页面显式写进槽位的 Content-Disposition 无关;
+ * 页面把 file.name 一起记入本地清单,取回时按 manifest 还原 —— 更贴近「按文件名取回」。
+ */
+export function buildSlots({ sts, count, slotPrefix = '', expires }) {
+  const slots = [];
+  const base = `${sts.prefix}${slotPrefix ? slotPrefix.replace(/^\/+|\/+$/g, '') + '/' : ''}`;
+  for (let i = 0; i < count; i++) {
+    const n = String(i).padStart(4, '0');
+    const key = `${base}${n}.bin`;
+    const url = presignV4({
+      ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
+      endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
+      key, method: 'PUT', expires,
+    });
+    slots.push({
+      n,
+      key,
+      contentType: 'application/octet-stream',
+      url,
+      publicUrl: `${SITE_BASE}/${key}`,
+    });
+  }
+  return slots;
+}
+
+// ============================================================================
+// 页面渲染 + 部署
+// ============================================================================
+
+/** 渲染接收页(模板 + 内联 CONFIG,自包含单文件)。 */
+export function renderPage({ title, channelName, limitText, limitNote, publicBase, slots, ttlHours }) {
+  const tpl = fs.readFileSync(TEMPLATE_PATH, 'utf8');
+  const cfg = { publicBase, ttlHours, slots: slots.map((s) => ({ n: s.n, name: `${s.n}.bin`, url: s.url, publicUrl: s.publicUrl, contentType: s.contentType })) };
+  const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
+  // 配置 JSON 内联进 <script type="application/json">:需转义 </ 以免提前闭合脚本标签
+  const cfgJson = JSON.stringify(cfg).replace(/<\//g, '<\\/');
+  return tpl
+    .replace(/__TITLE__/g, esc(title))
+    .replace(/__CHANNEL_NAME__/g, esc(channelName))
+    .replace(/__LIMIT_TEXT__/g, esc(limitText))
+    .replace(/__LIMIT_NOTE__/g, esc(limitNote))
+    .replace(/__TTL_HOURS__/g, String(ttlHours))
+    .replace(/__CONFIG_JSON__/g, cfgJson);
+}
+
+/**
+ * 部署页面:用同一 STS 把 index.html 直传项目前缀(而不是塞进个人空间或独立站点项目)。
+ * 好处:仍在 STS 允许的 dev/<pid>/ 前缀内,key 与预期一致(规避站点 dist-deploy 重写坑)。
+ * 落手即 public-read,公网 https://app.fmode.cn/dev/<pid>/bigfile/index.html
+ */
+export async function deployPage(sts, html) {
+  const key = `${sts.prefix}bigfile/index.html`;
+  const url = presignV4({
+    ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
+    endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
+    key, method: 'PUT', expires: 3600,
+  });
+  const res = await fetch(url, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'text/html; charset=utf-8', 'x-obs-acl': 'public-read' },
+    body: html,
+    signal: AbortSignal.timeout(30000),
+  });
+  if (!res.ok) return { ok: false, status: res.status, body: (await res.text()).slice(0, 300) };
+  return { ok: true, key, url: `${SITE_BASE}/${key}` };
+}
+
+// ============================================================================
+// collect(取回)
+// ============================================================================
+
+/**
+ * 判「远端对象到位」:CDN 对不存在的路径可能回落 200 + text/html(占位页),
+ * 因此仅凭 200 不足 —— 必须排除 text/html(踩坑 #1)。
+ */
+export function looksLikeObject(status, contentType) {
+  if (status !== 200) return false;
+  const ct = String(contentType || '').toLowerCase();
+  if (ct.includes('text/html')) return false;
+  return true;
+}
+
+/**
+ * 扫描槽位:对每个槽位的公网 URL 发 HEAD,判定是否已上传。
+ * 不依赖 ListObjects(STS 策略不允许 List —— 实测 403)。
+ */
+export async function scanSlots(manifest) {
+  const found = [];
+  for (const s of manifest.slots) {
+    const url = `${SITE_BASE}/${s.key}`;   // key 已含 dev/<pid>/ 前缀,勿再叠加 publicBase
+    try {
+      const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
+      const ct = res.headers.get('content-type');
+      const len = Number(res.headers.get('content-length') || 0);
+      if (looksLikeObject(res.status, ct)) {
+        found.push({ n: s.n, key: s.key, bytes: len, contentType: ct, publicUrl: url });
+      }
+    } catch { /* 单槽位失败不阻断整体扫描 */ }
+  }
+  return found;
+}
+
+// ============================================================================
+// 主流程
+// ============================================================================
+
+/** 读取当前用户显示名(用于页面标题;缺失则回落到项目名/id)。非机密。 */
+export function resolveDisplayName() {
+  const candidates = [
+    path.join(os.homedir(), '.fmode', 'config', 'user.json'),
+    path.join(os.homedir(), '.fmode', 'config.json'),
+    '/opt/data/fmode-identity.json',
+  ];
+  for (const p of candidates) {
+    try {
+      if (!fs.existsSync(p)) continue;
+      const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
+      const n = j.displayName || j.username || j.user?.displayName || j.user?.username || j.userid || j.userId;
+      if (n && String(n).trim()) return String(n).trim();
+    } catch { /* next */ }
+  }
+  return null;
+}
+
+/**
+ * mint:领 STS → 建槽位 → 渲染 → 部署 → 写 mint.json / collect.sh。
+ * @returns {Promise<{ok:boolean, ...}>}
+ */
+export async function gatewayMint(opts = {}) {
+  const outDir = path.resolve(opts.outDir || process.cwd());
+  const slotsCount = Number(opts.slots || DEFAULT_SLOTS);
+  const ttlHours = Number(opts.hours || DEFAULT_TTL_HOURS);
+  const channelName = opts.channel || '企微入站(用户 → 我)';
+  const limitText = opts.limit || '20MB';
+  const limitNote = opts.note || `${channelName} 是可协商不掉的上游硬限,文件在到达我这一侧之前就被拒收;改本地上限无效。`;
+
+  const sessionToken = resolveSessionToken();
+  if (!sessionToken) {
+    return { ok: false, error: 'no_session_token', message: '未找到登录态 sessionToken(云函数/STS 通道均依赖它)' };
+  }
+
+  const proj = await resolveGatewayProject(sessionToken, { projectId: opts.projectId, projectSlug: opts.projectSlug });
+  if (!proj) return { ok: false, error: 'project_unresolved', message: '项目注册/解析失败(云函数 /fmode/project-register 未成功)' };
+
+  const sts = await mintSts(sessionToken, proj.projectId);
+  if (!sts) {
+    return { ok: false, error: 'sts_failed', message: `项目级 STS 签发失败(项目 ${proj.projectId} 无权限或 token 失效)` };
+  }
+
+  const slotPrefix = opts.slotPrefix != null ? opts.slotPrefix : `gateway/${Date.now()}`;
+  const slots = buildSlots({ sts, count: slotsCount, slotPrefix, expires: Math.min(ttlHours * 3600, 3600) });
+
+  const title = opts.title || `大文件接收网关 · ${resolveDisplayName() || proj.projectName || proj.projectId}`;
+  const html = renderPage({
+    title, channelName, limitText, limitNote,
+    publicBase: `${SITE_BASE}/${sts.prefix.replace(/\/$/, '')}`,
+    slots, ttlHours,
+  });
+
+  fs.mkdirSync(outDir, { recursive: true });
+  const indexPath = path.join(outDir, 'index.html');
+  fs.writeFileSync(indexPath, html, 'utf8');
+
+  const manifest = {
+    v: 1,
+    kind: 'fmode-bigfile-gateway',
+    createdAt: new Date().toISOString(),
+    ttlHours,
+    projectId: proj.projectId,
+    projectName: proj.projectName || null,
+    bucket: sts.bucket,
+    endpoint: sts.endpoint,
+    region: regionOf(sts.endpoint),
+    prefix: sts.prefix,
+    publicBase: `${SITE_BASE}/${sts.prefix.replace(/\/$/, '')}`,
+    slotPrefix,
+    slots: slots.map((s) => ({ n: s.n, key: s.key, name: `${s.n}.bin` })),
+    indexPath,
+  };
+  // mint.json 只含非机密信息(projectId / key 列表)——签名 URL 有 1h 时效且随 STS 失效,不入盘。
+  const manifestPath = path.join(outDir, 'mint.json');
+  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
+
+  // 部署页面(可 --no-deploy 跳过,仅本地生成)
+  let deployed = null;
+  if (!opts.noDeploy) {
+    deployed = await deployPage(sts, html);
+  }
+
+  // collect 包装脚本(取回用;内部再 mint 新鲜 STS,不落盘任何密钥)
+  const collectSh = path.join(outDir, 'collect.sh');
+  fs.writeFileSync(collectSh, renderCollectSh(__dirname), { mode: 0o755 });
+
+  const report = {
+    ok: true,
+    pageUrl: deployed && deployed.ok ? deployed.url : null,
+    pageKey: deployed && deployed.ok ? deployed.key : null,
+    deployError: deployed && !deployed.ok ? `${deployed.status} ${deployed.body}` : null,
+    slots: slotsCount,
+    ttlHours,
+    projectId: proj.projectId,
+    projectName: proj.projectName || null,
+    projectVia: proj.via,
+    prefix: sts.prefix,
+    publicBase: manifest.publicBase,
+    stsExpiresAt: sts.expiresAt,
+    indexPath,
+    manifestPath,
+    collectScript: collectSh,
+    collectHint: `bash ${collectSh} --list        # 只看哪些槽位已有文件\nbash ${collectSh} --out ~/inbox  # 下载到 ~/inbox 并登记台账`,
+    stsExpiresNote: `STS 真身有效期约 ${ttlHours}h;用户过时未传须重新 mint+部署(不可沿用旧页)`,
+  };
+  return report;
+}
+
+function renderCollectSh(scriptsDir) {
+  return `#!/usr/bin/env bash
+# 大文件网关取回器 —— 扫描到位文件 → 下载 → 台账。
+# 不落盘任何密钥:每次运行重新 mint 项目级 STS(1h),只输出预签名 GET URL。
+# 用法:
+#   bash collect.sh --list                 # 只列出已到位的槽位
+#   bash collect.sh --out ~/inbox          # 下载到 ~/inbox 并写 ledger.jsonl
+#   bash collect.sh --manifest mint.json --out ./received
+set -euo pipefail
+DIR="$(cd "$(dirname "$0")" && pwd)"
+NODE_BIN="\${NODE_BIN:-node}"
+exec "$NODE_BIN" "${scriptsDir}/gateway.mjs" collect --manifest "$DIR/mint.json" "$@"
+`;
+}
+
+// ============================================================================
+// CLI
+// ============================================================================
+
+function parseArgs(rest) {
+  const a = { _: [] };
+  for (let i = 0; i < rest.length; i++) {
+    const t = rest[i];
+    if (t.startsWith('--')) { const k = t.slice(2); const v = rest[i + 1]; if (v === undefined || v.startsWith('--')) { a[k] = true; } else { a[k] = v; i++; } }
+    else a._.push(t);
+  }
+  return a;
+}
+
+async function cmdMint(rest) {
+  const a = parseArgs(rest);
+  const outDir = a.out || a._[0] || path.join(process.cwd(), 'gateway-out');
+  const report = await gatewayMint({
+    outDir,
+    slots: a.slots,
+    hours: a.hours,
+    title: a.title,
+    channel: a.channel,
+    limit: a.limit,
+    note: a.note,
+    slotPrefix: a['slot-prefix'],
+    projectId: a['project-id'],
+    projectSlug: a['project-slug'],
+    noDeploy: !!a['no-deploy'],
+  });
+  if (!report.ok) { console.error(JSON.stringify(report, null, 2)); process.exit(2); }
+  if (a.json) { console.log(JSON.stringify(report, null, 2)); return 0; }
+  console.log('大文件接收网关已生成');
+  console.log('  页面 URL :', report.pageUrl || `(未部署:${report.deployError})`);
+  console.log('  本地文件 :', report.indexPath);
+  console.log('  槽位数   :', report.slots, '  有效期约:', report.ttlHours + 'h');
+  console.log('  项目     :', report.projectId, `(${report.projectVia})`);
+  console.log('  前缀     :', report.prefix);
+  console.log('  取回     :', report.collectHint.replace(/\n/g, '\n             '));
+  console.log('  ⚠️ 发用户前先真传一个小文件实测(本工具不改这条铁律)。');
+  return 0;
+}
+
+async function cmdCollect(rest) {
+  const a = parseArgs(rest);
+  const manifestPath = a.manifest || path.join(process.cwd(), 'mint.json');
+  if (!fs.existsSync(manifestPath)) { console.error('找不到 manifest:', manifestPath); process.exit(2); }
+  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+  const outDir = a.out ? path.resolve(a.out) : null;
+
+  console.log(`扫描槽位(${manifest.slots.length} 个)…`);
+  const found = await scanSlots(manifest);
+  if (found.length === 0) {
+    console.log('没有检测到任何已到位的文件。');
+    console.log('(若用户尚未上传,属正常;STS 过期需重新 mint+部署)');
+    return 0;
+  }
+  console.log(`检测到 ${found.length} 个已到位文件:`);
+  for (const f of found) console.log(`  [${f.n}] ${f.key}  ${f.bytes} B  ${f.contentType}`);
+
+  if (!outDir) { return 0; }   // --list 行为
+
+  const sessionToken = resolveSessionToken();
+  const sts = await mintSts(sessionToken, manifest.projectId);
+  if (!sts) { console.error('取回失败:无法 mint 新鲜 STS(sessionToken 失效或项目无权限)'); process.exit(2); }
+  fs.mkdirSync(outDir, { recursive: true });
+  const ledgerPath = path.join(outDir, 'ledger.jsonl');
+  let ok = 0;
+  for (const f of found) {
+    const url = presignV4({
+      ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
+      endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
+      key: f.key, method: 'GET', expires: 3600,
+    });
+    try {
+      const res = await fetch(url, { signal: AbortSignal.timeout(120000) });
+      if (!looksLikeObject(res.status, res.headers.get('content-type'))) { console.error(`  跳过 [${f.n}](非对象响应)`); continue; }
+      const buf = Buffer.from(await res.arrayBuffer());
+      const local = path.join(outDir, `${f.n}.bin`);
+      fs.writeFileSync(local, buf);
+      fs.appendFileSync(ledgerPath, JSON.stringify({ n: f.n, key: f.key, bytes: buf.length, contentType: f.contentType, localPath: local, at: new Date().toISOString() }) + '\n');
+      console.log(`  下载 [${f.n}] → ${local}  (${buf.length} B)`);
+      ok++;
+    } catch (e) { console.error(`  下载 [${f.n}] 失败: ${e.message}`); }
+  }
+  console.log(`完成:${ok}/${found.length} 已下载到 ${outDir};台账 ${ledgerPath}`);
+  return 0;
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const [cmd, ...rest] = argv;
+  if (cmd === 'mint' || cmd === 'generate' || cmd === 'gen') return cmdMint(rest);
+  if (cmd === 'collect' || cmd === 'fetch') return cmdCollect(rest);
+  console.log(`用法(大文件接收网关):
+  node gateway.mjs mint  [--slots 20] [--hours 1] [--out DIR] [--title T]
+                         [--channel "企微入站"] [--limit 20MB] [--note "..."]
+                         [--project-id ID | --project-slug slug] [--slot-prefix P]
+                         [--no-deploy] [--json]
+  node gateway.mjs collect --manifest mint.json [--out DIR]   # 无 --out 即 --list`);
+  return cmd === 'help' ? 0 : 2;
+}
+
+// 作为脚本直接运行时才执行(被 import 时不自动跑,避免干扰 uploader.mjs)
+if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
+  main().then((c) => process.exit(c)).catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
+}

+ 752 - 0
dist/skills/fmode-storage/scripts/uploader.mjs

@@ -0,0 +1,752 @@
+#!/usr/bin/env node
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+/**
+ * fmode-storage uploader — 对象存储上传/公开链接/ACL
+ * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
+ *
+ * v0.4.0(2026-09-23):新增**第0级 云函数预签名直传**(首选)。
+ *   容器只需 FMODE_SESSION_TOKEN,平台云函数 fmodeagent-upload-url 持有 AK/SK 并
+ *   签发 5 分钟预签名 PUT URL;对象键 user/<userid>/<ns>/<YYYYMM>/...,公网
+ *   https://s3.fmode.cn/<key>。解决"每个容器都要配 OBS AK/SK / storageProjectId"
+ *   的落地缺口(此前 34 台容器无一具备,技能只能打印向导退出)。
+ *
+ * v0.3.0 诚实凭据链(真因修复,见 CHANGELOG.md):
+ *   旧版第0级调用 POST /api/storage/credentials —— 该端点从未上线(404,
+ *   设计文档 fmode-studio/docs/obs-cdn/04-API设计.md 状态为"规划中"),
+ *   "登录即可上传"是伪自举。本版改为:
+ *
+ *   第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
+ *   第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)解析 AK/SK/endpoint/bucket
+ *   第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
+ *   第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)解析 AK/SK/endpoint/bucket
+ *   第3级 sessionToken + storageProjectId → POST /api/apig/deploy/huaweicloud
+ *         → 项目隔离 STS(权威端点,生产实测 200;/api/storage/credentials
+ *           设计端点从未上线——HEAD 探测 404 缓存于 .sts-probe.json)
+ *   第4级 项目级 ./.fmode/config.json
+ *   全失败:打印初始化向导指引并退出码 2,绝不伪装成功。
+ *
+ *   旧 sessionToken 自举保留为 --experimental-sts:仅当显式传入该参数
+ *   且端点探测返回 200 时启用(面向未来端点上线,默认关闭)。
+ *
+ * 用法:
+ *   node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
+ *   node uploader.mjs put <file> --key <objectKey> [--ns report] [--acl public-read] [--bucket ...]
+ *   node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
+ *   node uploader.mjs test
+ *   node uploader.mjs config
+ */
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { execSync, spawnSync } from 'child_process';
+import { fileURLToPath } from 'url';
+
+const HOME = os.homedir();
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// fmode 网关基址(平台 STS 签发端点所在)
+const FMODE_API_BASE = process.env.FMODE_API_BASE || 'https://server.fmode.cn';
+const STORAGE_CREDENTIALS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/storage/credentials`;
+// 自举权威端点(2026-09-12 生产实测 200):sessionToken + projectId → 项目隔离 STS
+const DEPLOY_STS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/apig/deploy/huaweicloud`;
+const DEFAULT_ENDPOINT = 'obs.cn-north-4.myhuaweicloud.com';
+const PROBE_CACHE_FILE = path.join(__dirname, '.sts-probe.json'); // 探测结果缓存(1 小时有效)
+const PROBE_TTL_MS = 60 * 60 * 1000;
+
+// ── 第0级(首选):云函数预签名直传 ─────────────────────────────────────────
+// fmodeagent-upload-url 云函数保管 AK/SK,按调用者身份签发 5 分钟有效的
+// OBS SigV2 预签名 PUT URL;容器只拿到 URL,密钥永不下发。
+// 对象键:user/<userid>/<namespace>/<YYYYMM>/<uuid>-<filename>,公网 https://s3.fmode.cn/<key>
+const UPLOAD_FN_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/functions`;
+const UPLOAD_FN_ID = process.env.FMODE_UPLOAD_FN_ID || process.env.FMODE_STORAGE_FN_ID || 'AlP56LCKFm';
+const CDN_BASE = (process.env.FMODE_CDN_BASE || 'https://s3.fmode.cn').replace(/\/$/, '');
+
+/** 打印初始化向导(全链失败时的唯一出口,绝不伪装成功) */
+function printWizard() {
+  console.error(`
+┌─ fmode-storage 初始化向导 ─────────────────────────────────────────────┐
+│ 当前版本需一次性配置 OBS 子账号 AK/SK;sessionToken 免配置自举待平台    │
+│ 端点上线(设计文档 fmode-studio/docs/obs-cdn/04-API设计.md,规划中)。  │
+├────────────────────────────────────────────────────────────────────────┤
+│ 步骤 1  向 Fmode 平台/管理员申请 OBS 子账号(邮件模板见 README.md       │
+│         "三分钟初始化"章节),拿到 AK/SK 及所属桶/终端节点。            │
+│ 步骤 2  执行初始化(任选其一):                                        │
+│           node uploader.mjs init --ak <AK> --sk <SK> \\                 │
+│               --endpoint obs.cn-north-4.myhuaweicloud.com \\           │
+│               --bucket <bucket>                                        │
+│         (省略参数则进入交互模式逐项询问)                              │
+│ 步骤 3  验证:node uploader.mjs test                                    │
+│         (上传 1KB 探针文件→删除→报告成功)                             │
+└────────────────────────────────────────────────────────────────────────┘`);
+}
+
+// ============================================================================
+// 平台签发端点探测(B 节:诚实探测 + 1 小时缓存)
+// ============================================================================
+
+/**
+ * HEAD 探测平台 STS 签发端点是否上线。结果缓存本文件 .sts-probe.json(1 小时)。
+ * @returns {Promise<{online: boolean, checkedAt: number, status: number|null}>}
+ */
+export async function probeStsEndpoint() {
+  // 读缓存
+  try {
+    const j = JSON.parse(fs.readFileSync(PROBE_CACHE_FILE, 'utf8'));
+    if (j.checkedAt && Date.now() - j.checkedAt < PROBE_TTL_MS) return j;
+  } catch { /* 无缓存或损坏 → 重新探测 */ }
+  let status = null;
+  try {
+    const res = await fetch(STORAGE_CREDENTIALS_URL, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
+    status = res.status;
+  } catch { /* 网络失败视为未上线,不给假希望 */ }
+  const result = { online: status === 200, checkedAt: Date.now(), status };
+  try { fs.writeFileSync(PROBE_CACHE_FILE, JSON.stringify(result)); } catch { /* 只读环境忽略 */ }
+  return result;
+}
+
+/** 探测缓存是否可以强制刷新(--refresh-probe / init / test 命令场景) */
+function invalidateProbeCache() {
+  try { fs.rmSync(PROBE_CACHE_FILE, { force: true }); } catch { /* ignore */ }
+}
+
+// ============================================================================
+// 旧第0级自举(--experimental-sts 专用,端点上线前是死代码路径)
+// ============================================================================
+
+/**
+ * 解析 sessionToken(优先级:FMODE_SESSION_TOKEN → user.json(登录流程写入的最新 token)
+ * → ~/.fmode/config.json → ./.fmode/config.json
+ * → /opt/data/fmode-identity.json → ~/.fmode-harness-agent/fmode-identity.json)。
+ *
+ * ⚠️ 最后两个 fmode-identity.json 是**兜底**,顺序刻意放在 .fmode/config 之后:
+ * 容器普遍有 /opt/data/fmode-identity.json(开通时写入,字段名 session_token 下划线),
+ * 而 ~/.fmode/config.json 不一定存在。不放兜底会导致「有身份文件却报缺 token」,
+ * 技能只能打印初始化向导并退出。实测 34 台容器中 32 台只有该文件带 token。
+ */
+export function resolveSessionToken() {
+  if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
+  const candidates = [
+    path.join(HOME, '.fmode', 'config', 'user.json'),
+    path.join(HOME, '.fmode', 'config.json'),
+    path.join(process.cwd(), '.fmode', 'config.json'),
+    '/opt/data/fmode-identity.json',                              // 兜底:容器开通时写入
+    path.join(HOME, '.fmode-harness-agent', 'fmode-identity.json'), // 兜底:旧 harness 布局
+  ];
+  for (const p of candidates) {
+    try {
+      if (!fs.existsSync(p)) continue;
+      const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
+      const t = j.sessionToken || (j.user && j.user.sessionToken) || j.session_token || null;
+      if (t && String(t).trim()) return String(t).trim();
+    } catch { /* try next source */ }
+  }
+  return null;
+}
+
+/**
+ * 解析 storage 专用 projectId(非密钥)。来源优先级:FMODE_STORAGE_PROJECT_ID 环境变量
+ * → ~/.fmode/config.json / user.json 的 storageProjectId → ./.fmode/deploy.json 的 projectId。
+ */
+export function resolveStorageProjectId() {
+  if (process.env.FMODE_STORAGE_PROJECT_ID) return process.env.FMODE_STORAGE_PROJECT_ID.trim();
+  const candidates = [
+    path.join(HOME, '.fmode', 'config.json'),
+    path.join(HOME, '.fmode', 'config', 'user.json'),
+    path.join(process.cwd(), '.fmode', 'deploy.json'),
+  ];
+  for (const p of candidates) {
+    try {
+      if (!fs.existsSync(p)) continue;
+      const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
+      const id = j.storageProjectId || j.projectId || null;
+      if (id && String(id).trim()) return String(id).trim();
+    } catch { /* try next source */ }
+  }
+  return null;
+}
+
+/**
+ * 第3级(权威,2026-09-12 生产实测 200):sessionToken + projectId → 项目隔离 STS。
+ *
+ * 权威链路(future-server api/api-ncloud/apig/routes-deploy.js):
+ *   POST /api/apig/deploy/huaweicloud {token, projectId}
+ *     → { code:200, data:{ accessKey, secretKey, securityToken, obsPath } }
+ *     → obsPath "obs://nova-cloud/dev/<projectId>/" 解析出 bucket/prefix
+ * 服务端权限:token→用户身份 → Project.user / Project.owner / ProjectTeam 三查。
+ * ⚠️ 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
+ */
+export async function fetchStsViaDeploy(sessionToken, projectId) {
+  if (!sessionToken || !projectId) return null;
+  try {
+    const res = await fetch(DEPLOY_STS_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ token: sessionToken, projectId }),
+      signal: AbortSignal.timeout(20000),
+    });
+    if (!res.ok) return null;
+    const body = await res.json().catch(() => null);
+    const data = body && body.data;
+    if (!body || body.code !== 200 || !data || !data.success) return null;
+    const obsPath = String(data.obsPath || '');
+    const m = obsPath.match(/^obs:\/\/([^/]+)\/(.*)\/$/);
+    return {
+      ak: String(data.accessKey || ''),
+      sk: String(data.secretKey || ''),
+      securityToken: String(data.securityToken || ''),
+      expiresAt: data.expiresAt || null,
+      projectId,
+      // nova-cloud 桶在 cn-south-1(实测 2026-09-12;north-4 会 NoSuchBucket)
+      endpoint: 'obs.cn-south-1.myhuaweicloud.com',
+      bucket: m ? m[1] : 'nova-cloud',
+      prefix: m ? m[2] + '/' : `dev/${projectId}/`,
+    };
+  } catch { return null; }
+}
+
+/**
+ * 备用(任务书 B 节预留):设计文档端点 /api/storage/credentials。
+ * ⚠️ 该端点从未上线(HEAD 探测 404),端点上线前必然返回 null。仅 --experimental-sts 时调用。
+ * 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
+ */
+export async function fetchStsCredentials(sessionToken) {
+  if (!sessionToken) return null;
+  try {
+    const res = await fetch(STORAGE_CREDENTIALS_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sessionToken}` },
+      body: JSON.stringify({ op: ['put', 'delete'] }),
+      signal: AbortSignal.timeout(15000),
+    });
+    if (!res.ok) return null;
+    const body = await res.json().catch(() => null);
+    const cred = body && (body.credentials || body.data);
+    if (!cred || !cred.accessKeyId || !cred.secretAccessKey) return null;
+    return {
+      ak: cred.accessKeyId,
+      sk: cred.secretAccessKey,
+      securityToken: cred.sessionToken || cred.securityToken || '',
+      endpoint: cred.endpoint || DEFAULT_ENDPOINT,
+      bucket: cred.bucket || null,
+      prefix: cred.prefix || '',
+    };
+  } catch { return null; }
+}
+
+/** STS 签发作用域限定 key 必须在 prefix 内(dev/<projectId>/...),防越权路径 */
+export function scopedKey(cfg, key) {
+  if (cfg.sts && cfg.sts.prefix) {
+    const pfx = cfg.sts.prefix.endsWith('/') ? cfg.sts.prefix : cfg.sts.prefix + '/';
+    if (key.startsWith(pfx)) return key;
+    return pfx + key.replace(/^\/+/, '');
+  }
+  return key;
+}
+
+/**
+ * 用 STS 临时凭证构造一次性 obsutil 配置目录,执行命令后即删。
+ * (配置经 -config= 显式传给 obsutil——旧版 OBSUTIL_CONFIG_FILE 环境变量
+ *  obsutil 并不识别,属无效传递,本版修正。)
+ */
+export function runWithSts(sts, args, timeout = 300000) {
+  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-sts-'));
+  fs.chmodSync(tmpDir, 0o700);
+  const cfgFile = path.join(tmpDir, '.obsutilconfig');
+  fs.writeFileSync(cfgFile, [
+    `endpoint=${sts.endpoint}`,
+    `ak=${sts.ak}`,
+    `sk=${sts.sk}`,
+    `token=${sts.securityToken}`,
+    '',
+  ].join('\n'), { mode: 0o600 });
+  try {
+    return runObsutil(cfg.obsUtilPath, args, { cfgFile, timeout });
+  } finally {
+    try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
+  }
+}
+
+// ============================================================================
+// obsutil 执行层
+// ============================================================================
+
+/** 统一 obsutil 调用。成功判定用退出码 + 末行无 "failed",杜绝"failed"文案误判为成功。 */
+function runObsutil(obsUtilPath, args, { cfgFile = null, timeout = 120000 } = {}) {
+  const r = spawnSync(obsUtilPath, [...args, ...(cfgFile ? [`-config=${cfgFile}`] : [])], {
+    encoding: 'utf8',
+    timeout,
+  });
+  const out = (r.stdout || '') + (r.stderr || '');
+  const ok = r.status === 0 && !/failed/i.test(out.split('\n').filter(Boolean).pop() || '');
+  return { ok, out, status: r.status };
+}
+
+/** 公开 URL: cdnDomain 优先, 否则桶域名 */
+export function publicUrl(cfg, key) {
+  if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
+  return `https://${cfg.bucket}.${cfg.endpoint}/${key}`;
+}
+
+
+// ============================================================================
+// 第0级:云函数预签名直传(免 AK/SK,首选)
+// ============================================================================
+
+/**
+ * 申请预签名 URL 并直传(云函数持有真实 AK/SK)。
+ * @returns {{ok:true, key:string, url:string, bytes:number, via:string}|null}
+ *          null = 该通道不可用(无 sessionToken / 云函数未配置 / 网络失败),调用方回落下一级
+ */
+// ── MIME 推断(v1.2.0,缺陷报告 P0)───────────────────────────────────────
+// 预签名 URL 的签名**绑定 Content-Type**,必须在申请时就把正确的 mimeType 传给云函数;
+// PUT 阶段再改头会 403 SignatureDoesNotMatch。不传则云函数默认 application/octet-stream,
+// 浏览器对 octet-stream 是「下载」而非「渲染」→ HTML 报告分享后无法直接浏览。
+const MIME_MAP = {
+  '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
+  '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8',
+  '.mjs': 'application/javascript; charset=utf-8',
+  '.json': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8',
+  '.md': 'text/markdown; charset=utf-8', '.csv': 'text/csv; charset=utf-8',
+  '.xml': 'application/xml; charset=utf-8', '.svg': 'image/svg+xml',
+  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
+  '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon',
+  '.pdf': 'application/pdf', '.zip': 'application/zip',
+  '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4',
+  '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
+  '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
+  '.doc': 'application/msword',
+  '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+  '.xls': 'application/vnd.ms-excel',
+  '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+  '.ppt': 'application/vnd.ms-powerpoint',
+  '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+};
+
+/** 按扩展名推断 MIME;未知类型返回 application/octet-stream(保持旧行为) */
+export function guessMime(file) {
+  return MIME_MAP[path.extname(String(file)).toLowerCase()] || 'application/octet-stream';
+}
+
+/**
+ * --key 去重(缺陷报告 P1):云函数会自动补 `user/<调用者id>/` 前缀。
+ * 若 key 里已经写了 `user/<某id>/...`,会变成双层 `user/<id>/user/<id>/...`。
+ * 因为云函数在服务端强制把 key 挂到**调用者自己**的前缀下,剥离这段一定是安全且正确的。
+ */
+export function normalizeKey(key) {
+  const m = String(key || '').match(/^user\/[^/]+\/(.+)$/);
+  if (m) {
+    console.error(`[warn] --key 不应带 "user/<id>/" 前缀(云函数会自动补),已自动剥离:${key} → ${m[1]}`);
+    return m[1];
+  }
+  return key;
+}
+
+export async function putViaCloudFunction(file, objectKey, namespace = 'report', name = null, mimeType = null) {
+  const token = resolveSessionToken();
+  if (!token) return null;
+  let buf;
+  try { buf = fs.readFileSync(file); } catch { return null; }
+  const filename = name || path.basename(file);
+  let res;
+  try {
+    res = await fetch(UPLOAD_FN_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        token, id: UPLOAD_FN_ID,
+        params: {
+          filename, size: buf.length, namespace, key: objectKey || undefined,
+          ...(mimeType ? { mimeType } : {}),
+        },
+      }),
+      signal: AbortSignal.timeout(20000),
+    });
+  } catch { return null; }
+  if (!res.ok) return null;
+  const body = await res.json().catch(() => null);
+  if (!body || body.code !== 200 || !body.uploadUrl) return null;
+  try {
+    const put = await fetch(body.uploadUrl, { method: 'PUT', headers: body.headers || {}, body: buf });
+    if (!put.ok) return null;
+  } catch { return null; }
+  return {
+    ok: true, key: body.key, url: body.publicUrl || `${CDN_BASE}/${body.key}`,
+    bytes: buf.length, mimeType: mimeType || 'application/octet-stream',
+    via: `level0:cloudfunc(${UPLOAD_FN_ID})`,
+  };
+}
+
+// ============================================================================
+// 凭据链:诚实 4 级
+// ============================================================================
+
+/** 解析 obsutil config 文件内容 → {endpoint, ak, sk, token, bucket}。密钥不外泄,仅内部使用。 */
+function parseObsutilConfig(text) {
+  const c = {};
+  for (const line of String(text).split('\n')) {
+    const m = line.match(/^(endpoint|ak|sk|token)=(.*)$/);
+    if (m) c[m[1]] = m[2].trim();
+  }
+  return (c.ak && c.sk) ? c : null;
+}
+
+/** obsutil config 文件候选路径(OBSUTIL_CONFIG 环境变量优先,其次 ~/.obsutilconfig) */
+function obsutilConfigPaths() {
+  const paths = [];
+  if (process.env.OBSUTIL_CONFIG) paths.push(process.env.OBSUTIL_CONFIG);
+  paths.push(path.join(HOME, '.obsutilconfig'));
+  // getpwuid 的 home 可能与 $HOME 不同(容器常见):obsutil 实际按它解析默认配置
+  try {
+    const pwHome = os.userInfo().homedir;
+    if (pwHome && pwHome !== HOME) paths.push(path.join(pwHome, '.obsutilconfig'));
+  } catch { /* ignore */ }
+  return paths;
+}
+
+/** 定位 obsutil 可执行文件:PATH → ~/.local/bin → ~/bin → getpwuid home/bin → /usr/local/bin */
+function findObsutil() {
+  const candidates = [];
+  for (const dir of (process.env.PATH || '').split(':')) {
+    if (dir) candidates.push(path.join(dir, 'obsutil'));
+  }
+  for (const base of [path.join(HOME, '.local', 'bin'), path.join(HOME, 'bin')]) candidates.push(path.join(base, 'obsutil'));
+  try {
+    const pwHome = os.userInfo().homedir;
+    if (pwHome && pwHome !== HOME) candidates.push(path.join(pwHome, 'bin', 'obsutil'));
+  } catch { /* ignore */ }
+  candidates.push('/usr/local/bin/obsutil', '/usr/bin/obsutil');
+  for (const p of candidates) {
+    try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* next */ }
+  }
+  return 'obsutil'; // 交给 spawnSync 报"not found"
+}
+
+/**
+ * 诚实 4 级凭据解析(命中即用,检测不到就换下一级):
+ *   第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
+ *   第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)
+ *   第3级 sessionToken + storageProjectId → deploy STS(端点探测见 fetchStsCredentials 注释)
+ *   第4级 项目级 ./.fmode/config.json
+ */
+export async function resolveStorageConfig({ experimentalSts = false } = {}) {
+  const cfg = {
+    obsUtilPath: 'obsutil',
+    endpoint: DEFAULT_ENDPOINT,
+    bucket: null,
+    cdnDomain: null,
+    sts: null,
+    via: null,       // 命中的解析级别(诊断用,不含任何密钥)
+    ak: null,        // 内部传递用,config 命令绝不输出
+    sk: null,
+    token: '',
+  };
+  cfg.obsUtilPath = findObsutil();
+
+  // ---- 第1级:环境变量 ----
+  if (process.env.OBS_AK && process.env.OBS_SK) {
+    cfg.ak = process.env.OBS_AK.trim();
+    cfg.sk = process.env.OBS_SK.trim();
+    if (process.env.OBS_ENDPOINT) cfg.endpoint = process.env.OBS_ENDPOINT.trim();
+    if (process.env.OBS_BUCKET) cfg.bucket = process.env.OBS_BUCKET.trim();
+    cfg.via = 'level1:env(OBS_AK/OBS_SK)';
+  }
+
+  // ---- 第2级:obsutil config 文件 ----
+  if (!cfg.ak) {
+    for (const p of obsutilConfigPaths()) {
+      try {
+        if (!fs.existsSync(p)) continue;
+        const c = parseObsutilConfig(fs.readFileSync(p, 'utf8'));
+        if (c) {
+          cfg.ak = c.ak; cfg.sk = c.sk; cfg.token = c.token || '';
+          if (c.endpoint) cfg.endpoint = c.endpoint;
+          cfg.via = `level2:obsutilconfig(${p})`;
+          break;
+        }
+      } catch { /* try next path */ }
+    }
+  }
+
+  // ---- 第3级:平台签发 STS(deploy 权威链路,2026-09-12 生产实测 200)----
+  // sessionToken + storageProjectId → POST /api/apig/deploy/huaweicloud → 项目隔离 STS
+  // (设计文档中的 /api/storage/credentials 从未上线——HEAD 探测 404,.sts-probe.json 缓存;
+  //   端点若上线,见 fetchStsCredentials 的备用实现,仅 --experimental-sts 启用)
+  if (!cfg.ak) {
+    const sessionToken = resolveSessionToken();
+    if (sessionToken) {
+      const projectId = resolveStorageProjectId();
+      const sts = projectId ? await fetchStsViaDeploy(sessionToken, projectId) : null;
+      if (sts) {
+        // deploy 端点已实测 200(区别于 storage/credentials 的伪自举),默认启用;
+        // --experimental-sts 仅控制旧 storage/credentials 备用路径
+        cfg.sts = sts;
+        cfg.bucket = sts.bucket || cfg.bucket;
+        cfg.endpoint = sts.endpoint || cfg.endpoint;
+        cfg.cdnDomain = process.env.FMODE_CDN_DOMAIN || 'app.fmode.cn';
+        cfg.via = 'level3:sessionToken+projectId->deploySTS';
+        return cfg;
+      }
+      if (!projectId) {
+        console.error('sessionToken 存在但缺少 storageProjectId——请设置 FMODE_STORAGE_PROJECT_ID 或在 ~/.fmode/config.json 配 storageProjectId(须为本人有权限的 Project objectId)');
+      } else {
+        console.error('sessionToken 存在但 STS 签发失败(/api/apig/deploy/huaweicloud 未 200)——token 失效或该 projectId 不属于当前用户');
+      }
+      console.error('(将继续尝试第4级回落配置)');
+    } else {
+      // 无 sessionToken:检查设计文档端点是否上线(任务书 B 节:诚实探测,缓存 1 小时)
+      const probe = await probeStsEndpoint();
+      if (probe.online && experimentalSts) {
+        const token = resolveSessionToken() || process.env.FMODE_API_TOKEN || null;
+        const sts = token ? await fetchStsCredentials(token) : null;
+        if (sts) {
+          cfg.sts = sts;
+          cfg.bucket = sts.bucket || cfg.bucket;
+          cfg.endpoint = sts.endpoint || cfg.endpoint;
+          cfg.via = 'level3:experimental-storage-credentials';
+          return cfg;
+        }
+        console.error('--experimental-sts 已启用且设计端点在线,但换取失败');
+      } else if (probe.online) {
+        console.error('平台 STS 设计端点已上线(/api/storage/credentials)。加 --experimental-sts 启用该自举路径,或继续用 deploy/obsutil 配置模式。');
+      }
+    }
+  }
+
+  // ---- 第4级:项目级 ./.fmode/config.json ----
+  if (!cfg.ak && !cfg.sts) {
+    try {
+      const p = path.join(process.cwd(), '.fmode', 'config.json');
+      const j = JSON.parse(fs.readFileSync(p, 'utf8'));
+      if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
+      if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = `level4:${p}`; }
+      if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
+      if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
+    } catch { /* ignore */ }
+  }
+
+  // ---- bucket 补全:第1/2级命中但缺 bucket → obsutil ls 探测 ----
+  if (cfg.ak && !cfg.bucket) {
+    try {
+      const out = execSync(`${JSON.stringify(cfg.obsUtilPath)} ls -limit=1`, { encoding: 'utf8', timeout: 15000 });
+      const m = out.match(/obs:\/\/([^/\s]+)/);
+      if (m) { cfg.bucket = m[1]; cfg.via += '+bucket-probe'; }
+    } catch { /* 探测失败交给上层报错 */ }
+  }
+
+  return cfg;
+}
+
+// ============================================================================
+// 初始化向导(C 节:新用户路径)
+// ============================================================================
+
+/**
+ * node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
+ * 写入 obsutil config(600 权限)+ 本技能 config 段,然后立即 test。
+ */
+async function cmdInit(rest) {
+  const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
+  const ask = async (q) => {
+    process.stdout.write(q);
+    let buf = '';
+    for await (const chunk of process.stdin) { buf += chunk; break; }
+    return buf.trim();
+  };
+  const ak = arg('--ak') || await ask('AK: ');
+  const sk = arg('--sk') || await ask('SK: ');
+  const endpoint = arg('--endpoint') || await ask(`endpoint [${DEFAULT_ENDPOINT}]: `) || DEFAULT_ENDPOINT;
+  const bucket = arg('--bucket') || await ask('bucket: ');
+  if (!ak || !sk || !bucket) {
+    console.error('init 失败:AK/SK/bucket 均必填(--ak --sk --endpoint --bucket 或交互输入)');
+    process.exit(2);
+  }
+
+  // 1) 写 obsutil config(600)
+  const cfgPath = path.join(HOME, '.obsutilconfig');
+  fs.writeFileSync(cfgPath, [
+    `endpoint=${endpoint}`,
+    `ak=${ak}`,
+    `sk=${sk}`,
+    'token=',
+    'cname=false',
+    `endpointCrr=http://your-endpoint`,
+    `akCrr=*** Provide your Access Key ***`,
+    `skCrr=*** Provide your Secret Key ***`,
+    `tokenCrr=`,
+    '',
+  ].join('\n'), { mode: 0o600 });
+
+  // 2) 写本技能 config 段(~/.fmode/config.json,不含密钥本体)
+  const fmodeDir = path.join(HOME, '.fmode');
+  const fmodeCfg = path.join(fmodeDir, 'config.json');
+  fs.mkdirSync(fmodeDir, { recursive: true });
+  let j = {};
+  try { j = JSON.parse(fs.readFileSync(fmodeCfg, 'utf8')); } catch { /* 新文件 */ }
+  j.obsBucket = bucket;
+  j.obsEndpoint = endpoint;
+  fs.writeFileSync(fmodeCfg, JSON.stringify(j, null, 2) + '\n', { mode: 0o600 });
+  console.error(`已写入 ${cfgPath}(600)与 ${fmodeCfg}(技能 config 段,不含密钥本体)`);
+
+  // 3) 立即 test:上传 1KB 探针 → 删除 → 报告
+  return cmdTest();
+}
+
+/** test:上传 1KB 探针文件→删除→报告成功 */
+async function cmdTest() {
+  // 第0级:云函数免密钥通道(首选),能通就直接成功
+  const probeTmp = path.join(os.tmpdir(), `fmode-storage-cfprobe-${process.pid}.txt`);
+  fs.writeFileSync(probeTmp, 'fmode-storage cloudfunc probe ' + new Date().toISOString() + '\n');
+  try {
+    const r = await putViaCloudFunction(probeTmp, null, 'selftest');
+    if (r) {
+      console.log(JSON.stringify({ ok: true, via: r.via, url: r.url, bytes: r.bytes, message: '云函数免密钥通道自检成功(本机无需任何 AK/SK)' }, null, 2));
+      return true;
+    }
+  } finally {
+    try { fs.rmSync(probeTmp, { force: true }); } catch { /* ignore */ }
+  }
+
+  const cfg = await resolveStorageConfig();
+  if (!cfg.ak && !cfg.sts) { printWizard(); process.exit(2); }
+  if (!cfg.bucket) { console.error(`凭据已命中(${cfg.via})但未解析到 bucket:请 --bucket 指定或用 init 写入`); process.exit(2); }
+  const tmp = path.join(os.tmpdir(), `fmode-storage-probe-${process.pid}.txt`);
+  fs.writeFileSync(tmp, 'fmode-storage probe ' + new Date().toISOString() + '\n');
+  const key = `skill-storage-selftest/probe-${process.pid}.txt`;
+  try {
+    const up = runObsutil(cfg.obsUtilPath, ['cp', tmp, `obs://${cfg.bucket}/${key}`], { timeout: 120000 });
+    if (!up.ok) { console.error('test 失败(上传):', up.out.slice(-300)); process.exit(1); }
+    const del = runObsutil(cfg.obsUtilPath, ['rm', `obs://${cfg.bucket}/${key}`, '-f'], { timeout: 60000 });
+    if (!del.ok) { console.error('test 失败(清理探针文件,上传本身已成功):', del.out.slice(-300)); process.exit(1); }
+    console.log(JSON.stringify({ ok: true, bucket: cfg.bucket, endpoint: cfg.endpoint, via: cfg.via, probeKey: key, message: '上传 1KB 探针→删除 全部成功' }, null, 2));
+    return true;
+  } finally {
+    try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
+  }
+}
+
+// ============================================================================
+// main
+// ============================================================================
+
+async function main() {
+  const [cmd, ...rest] = process.argv.slice(2);
+  const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
+  const experimentalSts = rest.includes('--experimental-sts');
+  if (rest.includes('--refresh-probe')) invalidateProbeCache();
+
+  if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
+  if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
+
+  // ── gateway:大文件接收网关(委托 scripts/gateway.mjs)──────────────────────
+  // 用法: node uploader.mjs gateway <outDir>      → mint 到 <outDir>
+  //        node uploader.mjs gateway mint ...      → 透传子命令
+  //        node uploader.mjs gateway collect --manifest ...
+  if (cmd === 'gateway') {
+    const { main: gwMain } = await import('./gateway.mjs');
+    const sub = ['mint', 'gen', 'generate', 'collect', 'fetch', 'help'].includes(rest[0]) ? rest : ['mint', ...rest];
+    process.exit(await gwMain(sub));
+  }
+
+  // ── 第0级(首选,免密钥):put 直接走云函数预签名直传 ──────────────────
+  // 必须在凭据链解析之前:否则"4 级全未命中 → 打印向导 exit 2"会把这条路挡住,
+  // 而这条路恰恰是唯一不需要任何本机凭据的通道。
+  if (cmd === 'put') {
+    const file0 = rest[0];
+    const key0raw = arg('--key');
+    if (!file0 || !key0raw) { console.error('用法: put <file> --key <objectKey> [--ns report] [--mime text/html] [--acl public-read]'); process.exit(2); }
+    if (!fs.existsSync(file0)) { console.error('文件不存在: ' + file0); process.exit(2); }
+    const key0 = normalizeKey(key0raw);          // 剥离误写的 user/<id>/ 前缀(P1)
+    const ns0 = arg('--ns') || 'report';
+    const mime0 = arg('--mime') || guessMime(file0);   // 自动推断 MIME(P0)
+    const cf0 = await putViaCloudFunction(file0, key0, ns0, path.basename(key0), mime0);
+    if (cf0) {
+      console.log(JSON.stringify({ ok: true, key: cf0.key, url: cf0.url, bucket: 'storage-s3-nkkj', via: cf0.via, bytes: cf0.bytes, mimeType: cf0.mimeType }, null, 2));
+      console.error('[提示] 若覆盖了同名 key,CDN 缓存 TTL 为 30 天且缓存含 Content-Type 头;');
+      console.error('       分享出去的旧链接可能仍显示旧响应头。建议换新 key 路径(如 report-v2/index.html)发布。');
+      return;
+    }
+    // 云函数不可用(无 sessionToken / 云函数未配置)→ 落到下面的 obsutil 4 级兜底
+  }
+
+  // setacl:云函数通道下对象在 PUT 时已带 x-obs-acl:public-read,无需单独设 ACL
+  if (cmd === 'setacl' && resolveSessionToken()) {
+    console.log(JSON.stringify({ ok: true, via: 'level0:cloudfunc', message: '云函数通道:对象在上传时即带上 x-obs-acl:public-read,无需再单独设置 ACL' }, null, 2));
+    return;
+  }
+
+  const cfg = await resolveStorageConfig({ experimentalSts });
+  if (!cfg.ak && !cfg.sts) {
+    if (cmd !== 'help') {
+      console.error('凭据链 4 级全部未命中(env → obsutil config → 平台签发(sessionToken+projectId) → 项目 config)。');
+      printWizard();
+      process.exit(2);
+    }
+  }
+  if (cfg.ak && !cfg.sts && !cfg.bucket && ['put', 'setacl'].includes(cmd)) {
+    console.error(`凭据已命中(${cfg.via})但未解析到 bucket:用 --bucket 指定,或重新执行 init 写入完整配置。`);
+    process.exit(2);
+  }
+  const obs = (args, timeout = 120000) => cfg.sts
+    ? runWithSts(cfg.sts, args, timeout)
+    : runObsutil(cfg.obsUtilPath, args, { timeout });
+
+  if (cmd === 'put') {
+    const file = rest[0];
+    let key = arg('--key');
+    if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
+    const acl = arg('--acl') || 'public-read';
+    const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
+    // 第0级(首选):云函数预签名直传 —— 容器不需要任何 AK/SK
+    const ns = arg('--ns') || 'report';
+    const cf = await putViaCloudFunction(file, key, ns, path.basename(key || file));
+    if (cf) {
+      console.log(JSON.stringify({ ok: true, key: cf.key, url: cf.url, bucket: 'storage-s3-nkkj', via: cf.via, bytes: cf.bytes }, null, 2));
+      return;
+    }
+    key = scopedKey(cfg, key); // STS 签发时强制限定 dev/<projectId>/ 前缀,防越权路径
+    const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`, ...(acl ? ['-acl', acl] : [])]);
+    if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
+    console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket, via: cfg.via }, null, 2));
+    return;
+  }
+  if (cmd === 'setacl') {
+    const key = arg('--key'); const acl = arg('--acl') || 'public-read';
+    const recursive = rest.includes('-r');
+    const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])]);
+    if (!r.ok) { console.error('setacl 失败:', r.out.slice(-300)); process.exit(1); }
+    console.log(JSON.stringify({ ok: true }, null, 2));
+    return;
+  }
+  if (cmd === 'config') {
+    // 诊断输出:绝不包含 AK/SK/token/sessionToken 任何密钥本体
+    console.log(JSON.stringify({
+      via: cfg.via,
+      bucket: cfg.bucket,
+      endpoint: cfg.endpoint,
+      cdnDomain: cfg.cdnDomain,
+      credentialsResolved: Boolean(cfg.ak || cfg.sts),
+      stsBootstrapped: Boolean(cfg.sts),
+      stsPrefix: cfg.sts ? cfg.sts.prefix : null,
+      stsProjectId: cfg.sts && cfg.sts.projectId ? cfg.sts.projectId : null,
+      stsExpiresAt: cfg.sts && cfg.sts.expiresAt ? cfg.sts.expiresAt : null,
+    }, null, 2));
+    return;
+  }
+  console.log('用法: init | test | put | setacl | config | gateway <outDir>');
+}
+
+// 仅当作为脚本直接运行时执行 main();被 import(如 gateway.mjs)时不自动跑,
+// 避免 print 用法噪音或误触命令。
+if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
+  main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
+}

+ 158 - 0
dist/skills/fmode-storage/templates/gateway.html

@@ -0,0 +1,158 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<meta name="color-scheme" content="light">
+<title>__TITLE__</title>
+<style>
+  :root{
+    --ink:#1B2559; --sub:#64748B; --line:#E2E8F0; --card:#FFFFFF;
+    --bg:#F5F8FF; --accent:#5B5BD6; --accent-soft:#EEF0FF; --accent-line:#D5D9F5;
+    --accent2:#7C6FF0; --grad:linear-gradient(135deg,#5B5BD6 0%,#7C6FF0 60%,#9BB8F5 100%);
+    --ok:#1E7A46; --ok-soft:#EAF6EF; --warn:#8A6A1F; --warn-soft:#FAF4E6;
+    --radius:14px;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;padding:0}
+  body{
+    background:var(--bg); color:var(--ink); min-height:100vh;
+    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
+    line-height:1.55; -webkit-font-smoothing:antialiased;
+    padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
+  }
+  .wrap{max-width:720px;margin:0 auto;padding:20px 16px 56px}
+  header{padding:8px 0 4px}
+  .eyebrow{color:var(--accent);font-size:12px;font-weight:700;letter-spacing:.14em;text-transform:uppercase}
+  h1{font-size:22px;margin:6px 0 2px;line-height:1.3;word-break:break-word}
+  .sub{color:var(--sub);font-size:13.5px;margin:0 0 4px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px;margin-top:14px;box-shadow:0 1px 2px rgba(26,22,20,.03)}
+  .limit{background:var(--warn-soft);border:1px solid #EFE1BE;border-radius:var(--radius);padding:14px 16px;margin-top:14px}
+  .limit .big{font-weight:800;font-size:15px}
+  .limit .channel{color:var(--accent);font-weight:700}
+  .limit p{margin:6px 0 0;font-size:13px;color:var(--sub)}
+  .notice{display:flex;gap:10px;align-items:flex-start;background:var(--accent-soft);border:1px solid var(--accent-line);border-radius:12px;padding:12px 14px;margin-top:12px;font-size:13px;color:#7A1220}
+  .notice .ico{flex:0 0 auto;font-weight:800}
+  #drop{
+    margin-top:14px;border:2px dashed var(--accent-line);border-radius:var(--radius);
+    background:linear-gradient(180deg,#fff, #FFF9F9); padding:30px 18px; text-align:center;
+    cursor:pointer; transition:border-color .15s, background .15s;
+  }
+  #drop.hot{border-color:var(--accent);background:var(--accent-soft)}
+  #drop .big{font-size:16px;font-weight:700;margin-bottom:6px}
+  #drop .small{font-size:12.5px;color:var(--sub)}
+  #drop .pick{display:inline-block;margin-top:12px;background:var(--accent);color:#fff;font-weight:700;
+    border:0;border-radius:10px;padding:10px 20px;font-size:14.5px;cursor:pointer}
+  #drop .pick:active{transform:translateY(1px)}
+  .files{margin-top:16px;display:flex;flex-direction:column;gap:10px}
+  .f{border:1px solid var(--line);border-radius:12px;padding:12px 14px;background:#fff}
+  .f .row{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
+  .f .name{font-weight:600;font-size:13.5px;word-break:break-all}
+  .f .meta{color:var(--sub);font-size:12px;white-space:nowrap}
+  .f .slot{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--sub);margin-top:2px;word-break:break-all}
+  .bar{height:7px;border-radius:99px;background:#EFEAE3;overflow:hidden;margin-top:9px}
+  .bar > i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
+  .f.done .bar > i{background:var(--ok)}
+  .f.done{border-color:#CDE7D7;background:var(--ok-soft)}
+  .f.err .bar > i{background:#B8182A}
+  .status{font-size:12px;margin-top:6px;font-weight:600}
+  .status.ok{color:var(--ok)} .status.err{color:var(--accent)} .status.up{color:var(--sub)}
+  footer{color:var(--sub);font-size:11.5px;margin-top:22px;text-align:center;line-height:1.7}
+  code{background:#F2EDE6;border-radius:5px;padding:1px 5px;font-size:12px}
+  .hide{display:none}
+</style>
+</head>
+<body>
+<div class="wrap">
+  <header>
+    <div class="brand" style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
+      <span style="width:26px;height:26px;border-radius:8px;background:var(--grad);display:inline-flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:14px">F</span>
+      <span style="font-weight:800;font-size:13px;color:var(--accent)">未来飞马 FmodeAgent</span>
+      <span style="font-size:11px;color:var(--sub)">大文件接收网关</span>
+    </div>
+    <h1>__TITLE__</h1>
+    <p class="sub">拖入文件即直传对象存储,不占聊天通道 —— 多大都行。</p>
+  </header>
+
+  <div class="limit">
+    <div class="big">当前通道限制:<span class="channel">__CHANNEL_NAME__</span></div>
+    <div class="big" style="margin-top:2px">上限 __LIMIT_TEXT__</div>
+    <p>__LIMIT_NOTE__</p>
+  </div>
+
+  <div class="notice"><span class="ico">!</span><span>这是通道限制,不是你操作的问题。用本页直传存储,原文件多大都可以。</span></div>
+
+  <div id="drop">
+    <div class="big">拖拽文件到此处,或点击选择</div>
+    <div class="small">支持多文件同时上传 · 手机、电脑均可</div>
+    <button class="pick" id="pick" type="button">选择文件</button>
+    <input id="input" type="file" multiple class="hide">
+  </div>
+
+  <div class="files" id="files"></div>
+
+  <footer>
+    <div>上传地址由临时授权(STS)签发,约 __TTL_HOURS__ 小时内有效。</div>
+    <div>本页不含任何密钥;每个文件写入独立槽位,互不影响。</div>
+    <div id="slotsleft"></div>
+    <div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--line);color:#94A3B8;font-size:11px">由 <b style="color:var(--accent)">未来飞马 FmodeAgent</b> 提供技术支持 · fmode.cn</div>
+  </footer>
+</div>
+
+<script id="gateway-config" type="application/json">__CONFIG_JSON__</script>
+<script>
+(function(){
+  var CFG = JSON.parse(document.getElementById('gateway-config').textContent);
+  var PUB_BASE = CFG.publicBase, SLOTS = CFG.slots, TTL = CFG.ttlHours;
+  var drop = document.getElementById('drop'), input = document.getElementById('input'),
+      pick = document.getElementById('pick'), filesEl = document.getElementById('files');
+  var free = SLOTS.slice();
+  function fmt(n){ if(n<1024) return n+' B'; var u=['KB','MB','GB'], i=-1; do{ n/=1024; i++; }while(n>=1024&&i<2); return n.toFixed(n<10?1:0)+' '+u[i]; }
+  function esc(s){ return String(s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
+  pick.addEventListener('click', function(e){ e.stopPropagation(); input.click(); });
+  drop.addEventListener('click', function(){ input.click(); });
+  input.addEventListener('change', function(){ enqueue(input.files); input.value=''; });
+  ['dragenter','dragover'].forEach(function(ev){ drop.addEventListener(ev, function(e){ e.preventDefault(); drop.classList.add('hot'); }); });
+  ['dragleave','drop'].forEach(function(ev){ drop.addEventListener(ev, function(e){ e.preventDefault(); drop.classList.remove('hot'); }); });
+  drop.addEventListener('drop', function(e){ if(e.dataTransfer && e.dataTransfer.files) enqueue(e.dataTransfer.files); });
+
+  function updateLeft(){ var el=document.getElementById('slotsleft'); el.textContent = '剩余可用槽位 '+free.length+' / '+SLOTS.length; }
+  updateLeft();
+
+  function enqueue(fl){
+    for(var i=0;i<fl.length;i++){ (function(f){ uploadOne(f); })(fl[i]); }
+  }
+
+  function uploadOne(file){
+    var slot = free.shift();
+    var row = document.createElement('div');
+    row.className='f';
+    row.innerHTML = '<div class="row"><span class="name">'+esc(file.name)+'</span><span class="meta">'+fmt(file.size)+'</span></div>'
+      + '<div class="slot">'+(slot? '槽位 '+slot.n+' · '+esc(slot.name):'槽位已用尽')+'</div>'
+      + '<div class="bar"><i></i></div><div class="status up">'+ (slot? '等待上传…' : '无可用槽位,请重新生成页面') +'</div>';
+    filesEl.appendChild(row);
+    updateLeft();
+    if(!slot){ row.classList.add('err'); row.querySelector('.status').className='status err'; return; }
+    var bar = row.querySelector('.bar > i'), st = row.querySelector('.status');
+    var xhr = new XMLHttpRequest();
+    xhr.open('PUT', slot.url, true);
+    xhr.setRequestHeader('Content-Type', slot.contentType || 'application/octet-stream');
+    xhr.setRequestHeader('x-obs-acl', 'public-read');
+    st.textContent='上传中…'; st.className='status up';
+    xhr.upload.onprogress = function(e){ if(e.lengthComputable){ var p=Math.round(e.loaded/e.total*100); bar.style.width=p+'%'; st.textContent='上传中… '+p+'%'; } };
+    xhr.onload = function(){
+      if(xhr.status>=200 && xhr.status<300){
+        bar.style.width='100%'; row.classList.add('done'); st.className='status ok';
+        st.innerHTML = '上传成功 ✓ <a href="'+slot.publicUrl+'" target="_blank" rel="noopener">查看/下载</a>';
+      } else {
+        row.classList.add('err'); st.className='status err';
+        st.textContent='上传失败(HTTP '+xhr.status+')。若本页已打开超过 '+TTL+' 小时,请让智能体重新生成。';
+      }
+    };
+    xhr.onerror = function(){ row.classList.add('err'); st.className='status err'; st.textContent='网络中断,请重试或让智能体重新生成页面。'; };
+    xhr.send(file);
+  }
+})();
+</script>
+</body>
+</html>

+ 37 - 0
dist/skills/fmode-video-editing/README.md

@@ -0,0 +1,37 @@
+# skill-video-editing — 出圈TV 剪辑技能仓库
+
+**统一 Skill 封装开发理念** 的复杂技能范例仓库。
+
+## 我们的开发理念:统一 Skill 封装
+
+| 技能类型 | 开发方式 | 迭代方式 |
+|---|---|---|
+| **简单技能** | 聊天窗口与 AI 多轮交互:告诉它方法、给它 MD 文档,AI 自己封装、自己迭代 | 对话即开发,无需仓库 |
+| **复杂技能**(剪辑/数据分析/多步管线) | **独立 Git 仓库**:人用 Codex / IDE 等高级工具开发、调试、迭代 | push → Agent `git pull` → 加载 SKILL.md 即用 |
+
+**仓库即真源**:`SKILL.md` 是技能入口,`scripts/` 是 Agent 可直接调用的零交互脚本。
+
+## Agent 侧安装/更新(两条命令)
+
+```bash
+git -C <本仓库克隆目录> pull   # 更新到最新
+# 加载技能: skill_view('video-editing') → 按 SKILL.md 执行
+```
+
+## 人员协作(wangyong 等工程师)
+
+1. 克隆: `git clone https://git.fmode.cn/chuquantv/skill-video-editing.git`
+2. 开发: 用 Codex/IDE 迭代 `scripts/`(保持零交互、参数化、明确退出码)
+3. 提交: push 到 `main`,提交信息写清改动(如 `cut_ranges.py: 支持 vfr 时间基`)
+4. 验收: Agent 侧 pull 后真跑一条素材管线,结果贴回群
+
+## 当前状态
+
+- [x] SKILL.md 骨架(管线八步 + 发布铁律 + 协作模式)
+- [ ] scripts/ 五个基础脚本(cut_ranges / burn_subtitle / loudness_norm / make_cover / publish_release)
+- [ ] references/ 平台编码规格表
+- [ ] templates/ 剪映草稿与 ASS 样式
+- [ ] 首条真实素材端到端验收(粗剪→成片→s3.fmode.cn 发布)
+
+> 发布链接铁律: 一律 `https://s3.fmode.cn/<key>`(全小写),验收含视频 Range 206。
+> 完整标准: 数字生命知识库 `digital-life-standards/storage-standard.md`。

+ 60 - 0
dist/skills/fmode-video-editing/SKILL.md

@@ -0,0 +1,60 @@
+---
+name: "skill-video-editing"
+description: "触发词:剪辑、成片、粗剪、精剪、混剪、卡点、字幕烧录、封面。出圈TV剪辑师技能——素材整理、粗剪精剪、字幕/音乐/封面、成片输出与发布。"
+version: "1.0.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [剪辑, 成片, 粗剪, 精剪, 混剪, 卡点, 字幕烧录, 封面]
+---
+
+# 视频剪辑技能(skill-video-editing)
+
+> 出圈TV 剪辑师技能仓库。人(工程师,用 Codex/IDE)与数字生命(Agent,装技能即用)
+> 共同在此迭代。**仓库即技能的唯一真源**。
+
+## 交付流程(标准管线)
+
+```
+① 素材入库      素材(原片/口播/花絮) → user/<userid>/media/<项目>/  (S3)
+② 粗剪          按脚本/拉片结论选片段 → EDL/剪映草稿/ffmpeg concat
+③ 精剪          卡点(BPM对齐) / 转场 / 调色(LUT) / 降噪响度(loudnorm -16 LUFS)
+④ 字幕          转写(fmode-listen) → SRT → 烧录(ass 样式)或软字幕
+⑤ 音乐/音效     卡点音乐、BGM(注意版权)、音量让位口播(sidechain)
+⑥ 封面          关键帧 + 标题字版式 → cover.jpg
+⑦ 成片输出      竖版1080x1920 H.264/AAC, ≤目标平台码率; 文件名: <项目>_v<N>.mp4
+⑧ 上传与交付    publish 到 s3.fmode.cn(见下) → 报告/链接交付
+```
+
+## 发布铁律(Storage 标准 v1.0,2026-08-29 用户定调)
+
+1. **对外链接一律 `https://s3.fmode.cn/<key>`(全小写)**——禁止 OBS 原生域名
+   (桶不渲染网页,必须走 CDN:`Content-Disposition: inline` + obs-website 静态托管回源)
+2. 只写 `user/<自己的userid>/` 前缀;他人前缀=越权
+3. 成片 `Content-Type: video/mp4`;验收 = HEAD 200 **且 Range 请求 206**(否则播放会挂)
+4. HTML 索引页资源全部相对路径
+
+## 与 Agent 的协作模式(开发理念)
+
+- **简单技能**:聊天窗口多轮交互,AI 按 MD 文档自己封装、自己迭代(无需仓库)
+- **复杂技能**(如剪辑):**独立 Git 仓库** = 人用 Codex/IDE 开发调试 + Agent 对应仓库安装
+  - 电脑上改完 push → Agent 侧 `git pull` 最新 → 加载 SKILL.md 即生效
+  - 脚本必须零交互、参数化、可被 Agent 直接调用(stdin/stdout、明确退出码)
+
+## 目录约定
+
+```
+SKILL.md              # 本文件: 技能入口(触发词/管线/铁律)
+scripts/              # 可执行脚本(ffmpeg 参数化封装), 每个脚本顶部写用法
+  cut_ranges.py       #   多段裁剪拼接
+  burn_subtitle.py    #   SRT/ASS 烧录
+  loudness_norm.py    #   响度归一(-16 LUFS)
+  make_cover.py       #   封面生成
+  publish_release.py  #   成片+索引页发布到 s3.fmode.cn
+references/           # 编码参数表、平台规格(抖音/视频号/B站)
+templates/            # 剪映草稿模板、ASS 字幕样式、EDL 模板
+```
+
+## 环境
+
+- ffmpeg ≥ 6(带 libx264/libass); fmode-listen(转写); obsutil 或 boto3(上传)
+- 上传凭证: 容器 env `CLOUD_SDK_AK/CLOUD_SDK_SK` 或 studio credentials API

+ 79 - 0
dist/skills/fmode-video-lapian/README.md

@@ -0,0 +1,79 @@
+# skill-video-lapian
+
+平台级**短视频拉片技能**: 下载 → 转写 → 抽帧 → 方法论逐段分析 → HTML 报告 → S3 个人空间发布。
+
+## 命名规则(全平台技能以后都遵守)
+
+```
+skill-<领域>-<功能>
+skill-video-lapian = 处理 video, 功能是 拉片(shot-by-shot breakdown)
+其他示例: skill-audio-zhuanxie / skill-image-haibao / skill-data-report
+```
+
+## 平台级设计(核心)
+
+任何数字生命克隆本仓库, 只配**自己的身份参数**就能跑全流程, 产物发布到**自己的** S3 个人空间:
+
+```bash
+# 飞马身份(每个生命不同)
+export FEME_USERID=<Parse objectId>       # 如 sr2WiPsDyQ / pODNJqbURI / dhv9yTqK6K
+export FEME_SESSION_TOKEN="r:..."         # 飞马会话
+export FEME_NEWAPI_TOKEN="sk-..."         # API计费(VOC网关/转写/调模型都用它)
+export FEME_STUDIO_URL=http://server.fmode.cn:<studio端口>
+
+# S3 个人空间
+export S3_BUCKET=storage-s3-nkkj
+export S3_REGION=cn-north-4
+export S3_ENDPOINT=https://obs.cn-north-4.myhuaweicloud.com
+# AK/SK: 优先 <STUDIO_URL>/api/storage/credentials 取临时凭证(1小时, 限prefix);
+# 过渡期未上线, 直接用容器 env 的 CLOUD_SDK_AK / CLOUD_SDK_SK
+```
+
+零硬编码: 全部凭据/路径从 env 或 `${FMODE_IDENTITY_FILE:-${FMODE_DATA_DIR:-/opt/data}/fmode-identity.json}` 读取
+(优先级 `FEME_*` env > identity.json > FMODE_* 兼容旧名)。
+
+## 快速开始
+
+```bash
+export FEME_USERID=... FEME_SESSION_TOKEN=... FEME_NEWAPI_TOKEN=...
+WD=/tmp/lapian-test
+python3 scripts/fetch_video.py --aweme-id 7677548065384877346 --out-dir $WD
+python3 scripts/fetch_comments.py --aweme-id 7677548065384877346 --workdir $WD   # 可选
+python3 scripts/transcribe.py --workdir $WD
+python3 scripts/frame_analysis.py --workdir $WD --max-frames 12
+python3 scripts/build_report.py --workdir $WD --title "拉片分析:示例"
+python3 scripts/publish.py --workdir $WD   # → 公开URL
+```
+
+依赖: python3.8+(纯标准库) / ffmpeg / node+npx。无需 pip 安装任何包。
+
+## 目录
+
+```
+SKILL.md                  # 技能入口: 触发词+流程+参数说明
+references/
+  methodology.md          # 拉片方法论(薛辉/郑经说/三把刀/小希/南门)
+  voc-api.md              # VOC采集接口(搜索/详情/评论)
+  s3-upload.md            # S3发布规范(virtual-hosted/ACL/相对路径)
+scripts/
+  common.py               # 共享库: 身份参数/VOC/S3 SigV4(stdlib)
+  fetch_video.py          # 下载视频(VOC网关或直链/本地)
+  transcribe.py           # fmode-listen 转写(分段时间戳)
+  fetch_comments.py       # 评论区采集(可选)
+  frame_analysis.py       # 抽帧+豆包视觉逐帧点评
+  build_report.py         # 生成HTML报告(内置mini-Jinja, 无第三方依赖)
+  publish.py              # S3上传+公开URL+逐产物校验
+templates/
+  report.html.j2          # 报告模板(暗色/手机自适应/相对路径)
+```
+
+## 报告示例
+
+听show·丽江酒吧《云南人的物欲》实测报告:
+`user/sr2WiPsDyQ/report/lapian/douyin/20260829/report.html`
+(端到端自测记录见 git log / 会话历史)
+
+## License / 治理
+
+- 内部技能, 随数字生命体系分发; 二进制产物只进 S3, 不进 git
+- 修改方法论请同步 `${FMODE_DATA_DIR:-/opt/data}/knowledge-base/编导/` 源文件

+ 145 - 0
dist/skills/fmode-video-lapian/SKILL.md

@@ -0,0 +1,145 @@
+---
+name: "skill-video-lapian"
+description: "平台级短视频拉片技能:下载→转写→抽帧→方法论逐段分析→HTML报告→S3个人空间发布。动态身份参数(每个数字生命用自己飞马身份/S3空间),零硬编码。触发词:拉片、拆解视频、逐帧分析、视频复盘。"
+version: "1.0.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [拉片, 拆解视频, 逐帧分析, 视频复盘]
+---
+
+# skill-video-lapian — 平台级拉片技能
+
+> 命名规则(全平台技能遵守): `skill-<领域>-<功能>`
+> skill-video-**lapian** = 处理 **video**, 功能是 **拉片**(逐段逐帧拆解)。
+> 本技能是**平台级**: 任何数字生命克隆本仓库后, 用**自己的身份参数**即可跑全流程,
+> 产物发布到**自己的** S3 个人空间。**零硬编码凭据**。
+
+## 何时用
+
+- 用户发来抖音/短视频链接说「拉片 / 拆解 / 逐帧分析 / 复盘这条为什么火」
+- 编导知识库沉淀: 把爆款视频拆成结构+技法+可复用模板
+- 批量研究某账号/某赛道的内容打法
+
+## 前置条件(生命周期初始化时配好)
+
+```bash
+# 飞马身份(每个生命不同, 见 ${FMODE_IDENTITY_FILE:-$FMODE_DATA_DIR/fmode-identity.json})
+export FEME_USERID=sr2WiPsDyQ            # ← 换成你自己的 Parse objectId
+export FEME_SESSION_TOKEN="r:..."        # 飞马会话
+export FEME_NEWAPI_TOKEN="sk-..."        # API计费token(调VOC网关/转写/模型全走它)
+export FEME_STUDIO_URL="http://server.fmode.cn:<studio端口>"   # 自己的studio端点
+# S3 (过渡期可用容器env; studio /api/storage/credentials 上线后自动切换)
+export CLOUD_SDK_AK=...   export CLOUD_SDK_SK=...
+export S3_BUCKET=storage-s3-nkkj  S3_REGION=cn-north-4
+export S3_ENDPOINT=https://obs.cn-north-4.myhuaweicloud.com
+```
+
+依赖: `python3.8+`(纯标准库, 无需 pip 装包) · `ffmpeg/ffprobe` · `node/npx`(fmode-listen)
+身份参数优先级: `FEME_*` 环境变量 > `fmode-identity.json` > 兼容旧名(FMODE_*)。
+**S3 只能写 `user/<自己的FEME_USERID>/` 前缀, 写他人前缀=越权。**
+
+## 全流程(5 步, 每步可独立重跑, 幂等)
+
+```bash
+REPO="${LAPIAN_REPO:-$(pwd)}"   # 本仓库克隆到的目录
+WD=/tmp/lapian-$(date +%s)                     # 工作目录
+
+# 1. 拿视频(三选一): 抖音/视频号短链 / 视频ID / 已有本地文件
+# 平台自动识别(douyin/weixin视频号/xiaohongshu/bilibili),用户贴什么链接都行:
+python3 $REPO/scripts/fetch_video.py --url "https://v.douyin.com/xxxx/" --out-dir $WD
+python3 $REPO/scripts/fetch_video.py --aweme-id 7677548065384877346 --out-dir $WD
+python3 $REPO/scripts/fetch_video.py --local /path/to/video.mp4 --out-dir $WD --platform douyin
+#    可选: 采评论区VOC(报告自动带上)
+python3 $REPO/scripts/fetch_comments.py --aweme-id 7677548065384877346 --workdir $WD --max 100
+
+# 2. 转写(fmode-listen → 讯飞LFASR, 分段时间戳, 毫秒)
+python3 $REPO/scripts/transcribe.py --workdir $WD
+
+# 3. 抽帧+豆包视觉逐帧点评(计费可控: --fps/--max-frames, 或 --skip-vision 只抽帧)
+python3 $REPO/scripts/frame_analysis.py --workdir $WD --fps 0.15 --max-frames 12
+
+# 4. 生成报告(暗色主题/手机自适应/分段点击跳转播放/词频情绪/VOC/相对路径)
+python3 $REPO/scripts/build_report.py --workdir $WD --title "拉片分析:XXX"
+
+# 5. 发布到自己的S3个人空间 → 得公开URL
+python3 $REPO/scripts/publish.py --workdir $WD
+# → https://s3.fmode.cn/user/<FEME_USERID>/report/lapian/douyin/<YYYYMMDD>/report.html
+```
+
+## 产物结构(S3 路径规范, 全英文小写)
+
+```
+user/<userid>/report/lapian/<平台简称>/<YYYYMMDD>/
+├── report.html      ← 报告入口(资源全部相对路径, 目录自包含)
+├── video.mp4 / audio.wav / transcript.json
+├── frames/f0001.jpg...
+└── assets/          # analysis.json / comments.json / meta.json
+```
+平台简称映射: douyin / xiaohongshu / weixin / bilibili / tiktok / kuaishou / shortdrama。
+发布后对每个产物做 HEAD 校验, 全 200 才算成功。
+
+## 报告包含
+
+1. 原视频播放器置顶(粘性顶栏), 每段「⏵播放这一段」跳转对应时间
+2. 逐段可折叠卡片: 时间戳+台词+抽帧截图(画面描述)+技法标签(导师·技法)+可复用模板
+3. 整体结构表(起承转合)+核心发现
+4. 词频TOP/情绪分布/语速(纯CSS条形图, 零外网依赖)
+5. 评论区VOC: 正/中/负分布+高赞TOP5+每条洞察
+6. 3 条可执行建议
+
+## 分析依据
+
+- `references/methodology.md` — 薛辉/郑经说/三把刀/小希/南门 技法表(点评提示词的来源)
+- `references/voc-api.md` — VOC 网关接口(搜索/详情/评论, 解析层级与坑)
+- `references/s3-upload.md` — S3 发布规范(virtual-hosted/ACL/相对路径/凭证回退)
+
+## 实现要点(读脚本前先看)
+
+- `scripts/common.py` — 身份参数解析/VOC调用/S3 SigV4 纯stdlib实现(不依赖boto3, OBS必须virtual-hosted寻址)
+- `scripts/build_report.py` — 内置 mini-Jinja(stdlib), 无需安装 jinja2;
+  LLM 点评失败自动回退规则启发式, 永不中断流程
+- 转写短路: transcript.json 已存在则跳过(转写按分钟计费, 别重复花钱)
+- 视频下载完整校验: ffprobe 时长异常即报错, 禁止截断件进入分析
+
+## JS/ESM 演进方向(JSS, 2026-08-30 起)
+
+核心函数逐步 JS/ESM 化(`js/` 目录, 纯 ESM 零依赖, Node/浏览器双端), Python 只保留必须的系统级操作(ffmpeg/网络/文件IO):
+
+- `js/type_classify.mjs` — **JSS-01 类型预判**: 拉片前先判类型。特征打分制(标签3分/标题2分/口播1分/节奏指纹2分/AIGC强制信号4分), 类型体系见 TYPES(aigc_music/aigc_shortdrama/aigc_animal/talking_head/vlog/drama/food/knowledge), 输出 `{primary, mode}` → mode 路由: `lapian`(方法论文拉片, 原流程) | `aigc_storyboard`(AIGC分镜仿制拉片, 新流程)。CLI: `node type_classify.mjs workdir` → classify.json
+- `js/aigc_storyboard.mjs` — **JSS-02 AIGC分镜+仿爆款长提示词**: ①歌词结构检测(detectLyricStructure, 段落优先级: sections.json 人工精修 > 自动检测) ②视觉注册表(人物/场景/风格词频提取) ③分镜表合成(shot×frame×lyric, 每镜含 remake_prompt 生词提示: 主体|场景|运镜|风格) ④六维评分 ⑤buildLongPrompt 生成可直接投喂 AIGC 创作智能体的长提示词({{核心人物}}/{{核心产品或概念}}占位符换皮复刻)。CLI: `node aigc_storyboard.mjs workdir` → aigc_remake.json + aigc_prompt.md
+- `js/aigc_report.mjs` — **JSS-03 AIGC拉片报告**: 暗色HTML, 分镜卡片(逐镜截图+生词提示+AI痕迹), 注册表/结构表/评分/长提示词/VOC全展示。CLI: `node aigc_report.mjs workdir` → aigc_report.html
+
+AIGC 拉片流程(mode=aigc_storyboard 时): 场景检测(`select='gt(scene,0.10)'` 双阈值, AIGC渐变转场阈值要低于真人剪辑) → 逐镜中点抽帧 → 批量视觉点评(4帧/批, AIGC专用提示词: char/scene/shot/style/aigc/desc 六字段) → 能量高潮分析(astats RMS 找副歌位) → 台词+唱段合成 sections.json → JSS-02/03。
+
+## 交付规范(硬性, 2026-08-30 用户确认)
+
+- **唯一标准交付 = 报告发布到用户 S3 个人空间(公开读) + 群里发公开链接**。不发文件附件(.md/.html 直传文件不是交付, 用户明确拒绝)。
+- **对外链接 host 必须是 `https://s3.fmode.cn/<key>`, 严禁用 OBS 原生域名 `storage-s3-nkkj.obs.cn-north-4.myhuaweicloud.com`** —— 报告的相对路径资源(iframe视频/截图/数据)只有经 s3.fmode.cn 反代才能被浏览器正常加载, OBS 原生域名的链接在浏览器里会加载异常。此规则 2026-08-29 用户已沟通, 任何"公开URL生成点"(public_url/publish脚本/临时拼URL)一律走 s3.fmode.cn。上传/签名的 host 仍是 OBS 原生(协议层), 只有**对外分发的链接**用 s3.fmode.cn。
+- 发布是流程必经步(第5步), 发布失败 = 流程未完成: 必须修复发布通道后重试, 或明确告知用户"发布被阻断+原因", 不得静默改用文件附件兜底。
+- 链接格式: `https://s3.fmode.cn/user/<userid>/report/lapian/<平台>/<YYYYMMDD>/<报告名>.html`(口播流程 report.html / AIGC 流程 aigc_report.html)。发布后必须逐产物 HEAD 200 校验通过(校验也用 s3.fmode.cn!)才算完成, 校验通过才发链接。
+
+## 坑(实测)
+
+- VOC 网关 401 先查 token 是否 `sk-` 开头且 Bearer 前缀完整
+- OBS path-style 上传报 `VirtualHostDomainRequired` → bucket 必须放进 Host
+- urllib 对该网关要先设 UA(默认 UA 偶发被 WAF 拦, curl 不拦)
+- fmode-listen 是异步转写, 长音频耐心等(脚本已设 30 分钟超时)
+- 豆包视觉模型: 用 `doubao-seed-2-0-pro-260215`(当前账号唯一可用豆包pro), FmodeCode 系无视觉
+- 同一视频当天重跑: publish.py 幂等覆盖同日期目录, 直接重传即可
+<<<<<<< HEAD
+
+## 微信视频号(weixin/channels)专用说明 2026-09-03
+
+- **链接形态**: 用户从微信里复制的链接常见两种——
+  1. `https://channels.weixin.qq.com/platform/post/wxv_xxxx`(网页端分享)
+  2. `https://finder.video.qq.com/251/...&vid=wxv_xxxx&...`(客户端外链)
+- **自动识别**: `--platform auto`(默认)按 URL 判平台,`wxv_` 前缀的视频 ID 会自动提取,用户不需要选平台
+- **详情接口**: 视频号走 `weixin/channels/fetch_one_video` → 失败降级 `weixin/channels/fetch` → 再失败**降级直连模式**(仅下载视频做拉片,不阻塞流程)
+- **与抖音的差异**: 视频号 meta 字段是 `title/desc/nickname/video_url`(非 aweme 结构),summary 已做归一;**评论区暂无视频号通道**,拉片报告的 VOC 区会标注"该平台暂不支持评论区采集"
+- **实测路径**: 拿到真实视频号链接后先跑一次 `fetch_video.py --url <链接> --out-dir /tmp/test-wx`,确认下载链路通,再给用户出报告
+=======
+- **抖音下载铁律(2026-08-29 三次坏文件实测)**: `play_addr` 流对非APP客户端返回**损坏流**(moov 声称259s但 mdat 截断, ffprobe 时长正常但解码报 Invalid NAL unit size)。**必须用 `download_addr.url_list[0]` + curl 带浏览器UA/Referer 下载**(该流是高码率 1958kbps 版本, 时长可能与 play 版差几秒)。**唯一可信校验 = `ffmpeg -xerror -i 全量解码`, ffprobe 时长正常/尾部分段解码都可能假阴性**。下载会话中断后转写/抽帧全部作废重跑(音频只覆盖已下载部分)。
+- **孤儿进程陷阱**: 会话中断后下载进程可能还活着继续写文件, 恢复会话先 `ps aux | grep fetch_video` + 隔几秒对比文件大小, 确认没有写入者再动手, 避免双写或误判。
+- **报告点评 LLM 空响应**: build_report.py 内置 call_llm 走 deepseek-v4-flash 会路由到 reasoning 模型, reasoning 吃满 max_tokens 导致 content 为空。解法: 自己(编排 agent)按 methodology.md 逐段写点评 → 写 `workdir/llm_out.json`(格式: `{"segments":[{"i","techniques","func","comment","reuse"}...], "structure_summary"}`) → build_report.py 自动优先采用(手动 > LLM > 启发式)。
+- **S3 发布的 storage API 是设计稿未部署**(2026-08-29 实测): `/api/storage/credentials`、`/api/storage/upload` 在 443 网关与各 studio 端口全部 404, fmode-identity.json 也无 CLOUD_SDK_AK/SK。**用跳板机通道: `scripts/publish_via_obs.py`** — 容器打包 → SFTP 到 server.fmode.cn(139.159.253.131) → 云端 obsutil(`/opt/obsutil/obsutil_linux_amd64_5.4.11/obsutil`, 自带 nkkj 主账套 AK/SK, `cp -acl=public-read -e=obs.cn-north-4...`, 旗标别加 `-p -f`) → OBS 公网 HEAD 校验。paramiko 连云服务器偶发 SSH banner 瞬断, 脚本已带重试。注意容器 tar 打包时 assets 类文件本地在根目录、需 arcname 映射到 assets/ 前缀。
+>>>>>>> d65e9489096639272bb3e0ecf63c77681fe7c2b8

+ 117 - 0
dist/skills/fmode-video-lapian/js/aigc_report.mjs

@@ -0,0 +1,117 @@
+/**
+ * aigc_report.mjs — AIGC 分镜拉片报告生成器(JSS-03)
+ * 读取 aigc_remake.json + aigc_prompt.md → 产出 aigc_report.html(暗色/手机自适应/相对路径)
+ * 纯 ESM, 零依赖。CLI: node aigc_report.mjs workdir
+ */
+import fs from "node:fs";
+
+const esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
+const fmtTs = (ms) => { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
+
+function scoreBars(dims) {
+  return dims.map((d) => `
+    <div class="dim"><div class="dim-head"><span>${esc(d.name)}</span><span>${d.score}</span></div>
+    <div class="bar"><i style="width:${d.score}%"></i></div><div class="note">${esc(d.note)}</div></div>`).join("");
+}
+
+function shotRows(board) {
+  return board.map((s) => {
+    const img = s.file ? `frames/${s.file}` : "";
+    return `<details class="shot"><summary>
+      <span class="idx">#${s.i + 1}</span><span class="t">${esc(s.t_label)}</span>
+      <span class="dur">${s.dur}s</span><span class="role">${esc(s.role.split("(")[0])}</span></summary>
+      <div class="shot-body">
+        ${img ? `<img loading="lazy" src="${img}" alt="shot ${s.i}">` : ""}
+        <p><b>画面:</b> ${esc(s.caption)}</p>
+        ${s.lyric ? `<p><b>音频:</b> ${esc(s.lyric)}</p>` : ""}
+        ${s.vision?.char ? `<p><b>主体:</b> ${esc(s.vision.char)}</p>` : ""}
+        ${s.vision?.shot ? `<p><b>运镜:</b> ${esc(s.vision.shot)}</p>` : ""}
+        ${s.vision?.style ? `<p><b>风格:</b> ${esc(s.vision.style)}</p>` : ""}
+        ${s.vision?.aigc ? `<p class="aigc"><b>AI痕迹:</b> ${esc(s.vision.aigc)}</p>` : ""}
+      </div></details>`;
+  }).join("");
+}
+
+export function render(remake, promptMd) {
+  const { video, type, structure, registry, storyboard, score, voc } = remake;
+  const st = video.stats || {};
+  return `<!DOCTYPE html><html lang="zh-CN" data-theme="dark"><head><meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>AIGC分镜拉片:${esc((video.desc || "").split("#")[0].trim())}</title><style>
+:root{--bg:#0a0e17;--surface:#141a28;--line:#232c42;--tx:#dbe4f5;--dim:#8b98b8;--acc:#7c6cff;--gold:#ffb84d;--green:#3ddc97;--red:#ff6b7a}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--tx);font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
+.wrap{max-width:860px;margin:0 auto;padding:16px}
+h1{font-size:20px}h2{font-size:17px;border-left:3px solid var(--acc);padding-left:10px;margin-top:32px}
+.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px;margin:10px 0}
+.stats{display:flex;flex-wrap:wrap;gap:8px}.stat{flex:1;min-width:90px;text-align:center;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:10px 4px}
+.stat b{display:block;font-size:18px;color:var(--gold)}.stat span{font-size:12px;color:var(--dim)}
+.badge{display:inline-block;background:var(--acc);color:#fff;border-radius:20px;padding:2px 12px;font-size:13px;margin-right:6px}
+video{width:100%;border-radius:12px;margin:10px 0;background:#000}
+details.shot{background:var(--surface);border:1px solid var(--line);border-radius:10px;margin:6px 0;overflow:hidden}
+details.shot summary{cursor:pointer;padding:9px 12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;list-style:none}
+details.shot summary::-webkit-details-marker{display:none}
+.idx{color:var(--acc);font-weight:700;min-width:36px}.t{color:var(--dim);font-size:13px}.dur{color:var(--gold);font-size:13px}
+.role{font-size:12px;color:var(--dim)}
+.shot-body{padding:4px 14px 14px}.shot-body img{width:100%;border-radius:8px;margin:6px 0}
+.shot-body p{margin:4px 0;font-size:14px}.aigc{color:var(--red)}
+.bar{height:8px;background:var(--line);border-radius:4px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--acc),var(--gold))}
+.dim{margin:10px 0}.dim-head{display:flex;justify-content:space-between;font-size:14px}.note{font-size:12px;color:var(--dim)}
+table{width:100%;border-collapse:collapse;font-size:13px}th,td{border:1px solid var(--line);padding:6px 8px;text-align:left}th{color:var(--gold);font-weight:600}
+pre{white-space:pre-wrap;word-break:break-word;background:#0d1220;border:1px solid var(--line);border-radius:10px;padding:14px;font-size:13px;max-height:420px;overflow:auto}
+a{color:var(--acc)}.sec{font-size:13px;color:var(--dim)}
+footer{text-align:center;color:var(--dim);font-size:12px;padding:24px 0}
+</style></head><body><div class="wrap">
+<h1>🕷️ AIGC 分镜拉片报告</h1>
+<div class="sec">${esc(video.author)} · <a href="${esc(video.url)}">原片</a> · ${fmtTs(video.duration_s * 1000)}</div>
+<div style="margin:10px 0">
+  <span class="badge">${esc(type.primaryLabel || "AIGC内容")}</span>
+  <span class="badge" style="background:var(--gold);color:#333">置信度 ${type.confidence ?? "?"}</span>
+  <span class="badge" style="background:#2a3350">复刻模式: 分镜仿制</span>
+</div>
+<video controls preload="metadata" src="video.mp4"></video>
+<div class="stats">
+  <div class="stat"><b>${(st.digg_count ?? 0).toLocaleString()}</b><span>👍 点赞</span></div>
+  <div class="stat"><b>${(st.comment_count ?? 0).toLocaleString()}</b><span>💬 评论</span></div>
+  <div class="stat"><b>${(st.collect_count ?? 0).toLocaleString()}</b><span>🔖 收藏</span></div>
+  <div class="stat"><b>${(st.share_count ?? 0).toLocaleString()}</b><span>↗ 转发</span></div>
+  <div class="stat"><b>${score.total}</b><span>综合评分</span></div>
+</div>
+
+<h2>🧬 复刻资产注册表</h2>
+<div class="card"><b>人物:</b> ${(registry.characters || []).map(esc).join("、") || "—"}<br>
+<b>场景:</b> ${(registry.scenes || []).map(esc).join("、") || "—"}<br>
+<b>风格:</b> ${(registry.styles || []).map(esc).join("、") || "—"}<br>
+<b>节奏:</b> ${structure.shot_count} 镜 / 平均 ${structure.avg_shot_s}s / ${(structure.shot_count / (video.duration_s / 60)).toFixed(0)} 镜每分钟</div>
+
+<h2>🎬 片体结构</h2>
+<table><tr><th>段落</th><th>时间</th><th>类型</th><th>内容</th></tr>
+${(structure.sections || []).map((s) => `<tr><td>${esc(s.name)}</td><td class="t">${s.start}s–${s.end}s</td><td>${esc(s.type || "")}</td><td>${esc(s.content || s.lyric || "")}</td></tr>`).join("")}
+</table>
+
+<h2>🎞️ 分镜表(${storyboard.length} 镜)</h2>
+${shotRows(storyboard)}
+
+<h2>📊 六维评分</h2>
+<div class="card">${scoreBars(score.dims)}</div>
+
+<h2>🧾 仿爆款长提示词</h2>
+<div class="sec">完整版: <a href="aigc_prompt.md" download>aigc_prompt.md</a> — 直接投喂给 AIGC 创作智能体, 替换占位符即可逐镜复刻</div>
+<pre>${esc(promptMd)}</pre>
+
+<h2>💬 评论区 VOC</h2>
+<div class="card">${(voc?.top || []).map((c) => `<div style="border-bottom:1px solid var(--line);padding:6px 0"><b style="color:var(--gold)">👍${c.likes}</b> ${esc(c.text)}</div>`).join("") || "—"}
+<div class="sec" style="margin-top:6px">共采集 ${voc?.total || 0} 条</div></div>
+
+<h2>🚀 改进与复刻策略</h2>
+<div class="card">${(remake.suggestions || []).map((s) => `<p>▸ ${esc(s)}</p>`).join("")}</div>
+<footer>skill-video-lapian · JSS-01/02/03 · ${new Date().toISOString().slice(0, 10)}</footer>
+</div></body></html>`;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const wd = process.argv[2] || ".";
+  const remake = JSON.parse(fs.readFileSync(`${wd}/aigc_remake.json`, "utf8"));
+  const promptMd = fs.existsSync(`${wd}/aigc_prompt.md`) ? fs.readFileSync(`${wd}/aigc_prompt.md`, "utf8") : "(未生成)";
+  fs.writeFileSync(`${wd}/aigc_report.html`, render(remake, promptMd));
+  console.log(`→ ${wd}/aigc_report.html (${(fs.statSync(`${wd}/aigc_report.html`).size / 1024).toFixed(0)} KB)`);
+}

+ 245 - 0
dist/skills/fmode-video-lapian/js/aigc_storyboard.mjs

@@ -0,0 +1,245 @@
+/**
+ * aigc_storyboard.mjs — AIGC 短剧/音乐分镜拆解与仿爆款长提示词生成(JSS-02)
+ *
+ * 输入(workdir 下):
+ *   shots.json      {duration, shot_count, shots:[{i,start,end,dur}]}
+ *   frames.json     [{i, t_ms, t_label, caption}]   ← 视觉逐帧点评(python 管线产出)
+ *   transcript.json {data:{segments:[{bg,ed,text}], text}}
+ *   meta.json       {desc, author, stats, tags, duration_ms}
+ *   comments.json   [{text, likes, nickname}]       ← 可选, VOC
+ *   classify.json   {primary, mode}                 ← type_classify.mjs 产出
+ *
+ * 输出(workdir 下):
+ *   aigc_remake.json  结构化分镜库(人物/场景/风格/节奏注册表 + 分镜表)
+ *   aigc_prompt.md    可直接投喂 AIGC 创作智能体的长提示词(带 {{核心人物}}/{{核心产品}} 占位符)
+ *
+ * 纯 ESM / 零依赖 / Node 与浏览器双端。
+ */
+
+// ---------- 工具 ----------
+const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
+const fmtTs = (ms) => { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
+const uniq = (a) => [...new Set(a)];
+const freq = (arr) => { const m = {}; for (const x of arr) m[x] = (m[x] || 0) + 1; return m; };
+const topN = (m, n) => Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ({ k, v }));
+
+// ---------- 歌词结构: 副歌检测(重复行聚类) ----------
+export function detectLyricStructure(segments) {
+  const lines = segments.map((s) => ({ t: s.bg, text: (s.text || "").trim() })).filter((l) => l.text.length > 1);
+  const norm = (t) => t.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "").toLowerCase();
+  const counts = freq(lines.map((l) => norm(l.text)));
+  const repeated = new Set(Object.keys(counts).filter((k) => counts[k] >= 2 && k.length >= 3));
+  // 连续重复区段 = 副歌
+  const sections = [];
+  let cur = null;
+  for (const l of lines) {
+    const isChorus = repeated.has(norm(l.text));
+    if (!cur || cur.chorus !== isChorus) {
+      if (cur) sections.push(cur);
+      cur = { chorus: isChorus, start: l.t, end: l.ed ?? l.t + 4000, lines: [l.text] };
+    } else { cur.lines.push(l.text); cur.end = l.ed ?? l.t + 4000; }
+  }
+  if (cur) sections.push(cur);
+  // 段落命名: 副歌/主歌/间奏(无词)
+  let v = 0, c = 0;
+  return sections.map((s, idx) => {
+    let name;
+    if (s.chorus) name = `副歌${++c || ""}`;
+    else if (s.lines.join("").length < 4) name = "间奏/纯音乐";
+    else name = `主歌${++v}`;
+    return { idx, name, chorus: s.chorus, start: s.start, end: s.end, lyric: s.lines.join(" / ").slice(0, 120) };
+  });
+}
+
+// ---------- 视觉注册表: 人物/场景/风格提取 ----------
+const CHAR_HINTS = ["女王", "女团", "和声", "舞者", "观众", "歌手", "男主", "女主", "主角", "机器人", "蜘蛛", "乐手", "乐队", "伴舞"];
+const SCENE_HINTS = ["舞台", "观众席", "后台", "城市", "街道", "夜景", "室内", "棚", "天空", "废墟", "宫殿", "洞穴", "森林", "海边", "机房", "大厅"];
+const STYLE_HINTS = ["霓虹", "赛博", "金属", "烟雾", "激光", "追光", "灯光", "金色", "红色", "紫色", "蓝色", "暗调", "高饱和", "质感", "毛发", "鳞片", "机械", "全息", "粒子", "镜头", "特写", "远景", "俯拍", "仰拍", "推近", "拉远"];
+
+function extractRegistry(captions) {
+  const text = captions.join(" ");
+  const pick = (hints) => topN(freq(hints.filter((h) => text.includes(h))), 10).map((x) => x.k);
+  return {
+    characters: pick(CHAR_HINTS),
+    scenes: pick(SCENE_HINTS),
+    styles: pick(STYLE_HINTS),
+  };
+}
+
+// ---------- 分镜表合成: shot × frame × lyric × section ----------
+export function buildStoryboard({ shots = [], frames = [], segments = [], sections = [] } = {}) {
+  const framesByShot = new Map(frames.map((f) => [f.i, f]));
+  const seenSegs = new Set();
+  const board = shots.map((s) => {
+    const t0 = Math.round(s.start * 1000), t1 = Math.round(s.end * 1000);
+    const frame = framesByShot.get(s.i) || {};
+    const lyricSegs = segments
+      .filter((g) => g.bg < t1 && (g.ed || g.bg + 4000) > t0);
+    // 每个转写段只在它覆盖的第一个镜头显示全文, 后续镜头显示〃(避免长段刷屏)
+    let lyric = "";
+    if (lyricSegs.length) {
+      const key = lyricSegs[0].bg;
+      lyric = seenSegs.has(key) ? "〃(同段)" : lyricSegs[0].text.trim();
+      seenSegs.add(key);
+    }
+    return {
+      i: s.i, start: s.start, end: s.end, dur: s.dur,
+      t_label: `${fmtTs(t0)}-${fmtTs(t1)}`,
+      caption: frame.caption || "(待视觉点评)",
+      lyric,
+      // 镜头角色推断
+      role: s.dur >= 4 ? "展示镜头(副歌记忆点/氛围铺陈)" :
+            s.dur >= 2 ? "叙事镜头(主体动作/歌词同步)" : "节奏镜头(快切/卡点/反应)",
+    };
+  });
+  // 段落归属(sections 秒坐标)
+  for (const s of board) {
+    const sec = sections.find((x) => s.start >= x.start && s.start < x.end);
+    s.section = sec ? sec.name : "纯音乐";
+    // AIGC 复刻提示: 视觉点评要素合成(可直接作为单镜生词提示词)
+    const v = framesByShot.get(s.i)?.vision || {};
+    s.remake_prompt = [v.char, v.scene, v.shot, v.style].filter(Boolean).join(" | ") ||
+      s.caption;
+  }
+  return board;
+}
+
+// ---------- 评分体系(6维, 保留口播版的评分传统) ----------
+export function scoreVideo({ meta = {}, shots = [], frames = [], lyricDensity = 0 } = {}) {
+  const stats = meta.stats || {};
+  const digg = stats.digg_count || 0, share = stats.share_count || 0, collect = stats.collect_count || 0;
+  const shareRate = digg ? share / digg : 0;
+  const durs = shots.map((s) => s.dur);
+  const avgShot = durs.length ? durs.reduce((a, b) => a + b, 0) / durs.length : 0;
+  const visionCoverage = shots.length ? frames.filter((f) => f.caption && !f.caption.startsWith("(待")).length / shots.length : 0;
+  const dims = [
+    { name: "传播力(转发/赞比)", score: clamp(Math.round(shareRate * 100), 1, 100), note: `转发/点赞=${(shareRate * 100).toFixed(0)}%` },
+    { name: "收藏价值(模仿意愿)", score: clamp(Math.round((collect / Math.max(digg, 1)) * 200), 1, 100), note: `收藏/点赞=${((collect / Math.max(digg, 1)) * 100).toFixed(0)}%` },
+    { name: "节奏设计", score: clamp(Math.round(avgShot ? 110 - avgShot * 12 : 40), 1, 100), note: `平均镜头${avgShot.toFixed(1)}s` },
+    { name: "镜头信息密度", score: clamp(Math.round(shots.length / 4), 1, 100), note: `${shots.length}个镜头` },
+    { name: "音乐画面同步", score: clamp(Math.round(lyricDensity * 100) || 60, 1, 100), note: `歌词覆盖率${(lyricDensity * 100).toFixed(0)}%` },
+    { name: "视觉可复制度", score: clamp(Math.round(visionCoverage * 100), 1, 100), note: `视觉点评覆盖${(visionCoverage * 100).toFixed(0)}%` },
+  ];
+  const total = Math.round(dims.reduce((s, d) => s + d.score, 0) / dims.length);
+  return { dims, total };
+}
+
+// ---------- 仿爆款长提示词生成 ----------
+export function buildLongPrompt({ meta = {}, classify = {}, registry = {}, board = [], sections = [], score = {} } = {}) {
+  const chorusSecs = sections.filter((s) => s.chorus);
+  const dialogSecs = sections.filter((s) => s.type === "dialog" && s.content);
+  const styleLine = registry.styles?.length ? registry.styles.slice(0, 8).join("、") : "电影感打光、高饱和舞台色";
+  const charLine = registry.characters?.length ? registry.characters.slice(0, 6).join("、") : "核心角色、伴演群体";
+  const sceneLine = registry.scenes?.length ? registry.scenes.slice(0, 5).join("、") : "主舞台、观众席";
+  const isVariety = sections.some((s) => s.type === "dialog" || s.type === "mixed");
+  const genreLine = isVariety
+    ? `**混合体裁**: 「音乐现场 × 综艺选秀」结构(台词报幕/评委互动/晋级投票 + 唱段表演) —— 这是 AIGC 内容的体裁创新点, 复刻时必须保留综艺叙事骨架`
+    : "**纯音乐现场**结构(前奏→主歌→副歌→间奏→收尾)";
+  const shotLines = board.map((s) => {
+    // sections 用秒, board.start 也是秒
+    const sec = sections.find((x) => s.start >= x.start && s.start < x.end);
+    const secName = sec ? sec.name : "纯音乐";
+    return `| ${s.i + 1} | ${s.t_label} | ${s.dur}s | ${secName} | ${s.role.split("(")[0]} | ${s.remake_prompt || s.caption} | ${s.lyric ? `「${s.lyric.slice(0, 28)}${s.lyric.length > 28 ? "…" : ""}」` : "(音乐)"} |`;
+  });
+  return `# AIGC 爆款复刻 · 长提示词
+> 源片: ${meta.author || "?"}《${(meta.desc || "").split("#")[0].trim()}》(${fmtTs(meta.duration_ms || 0)}, ${classify.primaryLabel || "AIGC内容"})
+> 用法: 整段投喂给 AIGC 视频创作智能体, 替换 {{核心人物}} / {{核心产品或概念}} 后按分镜表逐镜生成。
+
+## 一、角色设定(全局一致性锚点)
+- 核心人物: {{核心人物}} —— 原片为「${charLine}」; 复刻时保持: 形象特征贯穿全片不变(服装/配色/标志物/体型比例), 每个镜头生成时必须回读此锚点。
+- 核心产品或概念: {{核心产品或概念}} —— 原片为「音乐翻唱现场」; 替换后所有镜头的叙事焦点围绕它展开。
+- 群像: ${charLine}
+
+## 二、全局视觉风格(所有分镜共用)
+- 风格关键词: ${styleLine}
+- 画面基调: 舞台级打光、${sceneLine} 为主场景、强色彩记忆点
+- 画质要求: 电影级质感、4K、细节丰富、无文字水印、人物面部稳定不畸变
+- 负面约束: 画面抖动、人物变形、多指、闪烁、风格漂移(每个镜头生成后与上一镜头对比主体一致性)
+
+## 三、音频结构与体裁(复刻节拍骨架)
+- 体裁: ${genreLine}
+- 原片段落: ${sections.map((s) => s.name).slice(0, 8).join(" → ")}${sections.length > 8 ? " → …" : ""}
+- 换曲复刻: 保留「开场抓人→表演高潮→互动喘息→抉择收尾」的情绪曲线, 镜头时长配比可复用原片节奏
+${dialogSecs.length ? `
+## 三·B、台词本(综艺骨架, 换皮复刻)
+> 原片主持/评委台词(ASR转写), 复刻时按「报幕→悬念→互动→抉择」功能位替换内容:
+${dialogSecs.map((s) => `- 【${s.name}】${(s.content || "").slice(0, 100)}`).join("\n")}
+` : ""}
+## 四、分镜表(共 ${board.length} 镜, 直接逐行生成)
+| # | 时间 | 时长 | 段落 | 镜头角色 | 逐镜生词提示(主体\\|场景\\|运镜\\|风格) | 歌词/音频同步 |
+|---|---|---|---|---|---|---|
+${shotLines.join("\n")}
+
+## 五、逐镜生成规则
+1. 每个镜头独立生成, 但开头必须复述「角色锚点+风格关键词」再描述本镜画面(防风格漂移)。
+2. 镜头角色含义: 展示镜头=大景别+氛围渲染+角色高光; 叙事镜头=中景+动作与歌词对齐; 节奏镜头=1秒内快切, 用于卡鼓点/反应镜头。
+3. 副歌段落镜头优先分配最佳预算(最高画质/最复杂运镜)。
+4. 快切镜头(<1s)可用上一镜头尾帧图作为图生视频起点, 保证连贯。
+5. 全部镜头生成后按分镜表时间轴拼接, 音轨对齐歌词时间戳。
+
+## 六、交付规格
+- 画幅: 9:16 竖屏 · 时长: 对齐原曲结构(可等比缩放) · 帧率: 24/30fps
+- 拼接后全片检查: 主体一致性 / 色调统一 / 节奏与音乐对齐
+
+## 七、原片成绩(复刻目标基准)
+- ${meta.stats?.digg_count || "?"} 赞 · ${meta.stats?.share_count || "?"} 转发 · ${meta.stats?.collect_count || "?"} 收藏 · 综合${score.total ?? "?"}分
+- 爆款要素: 高密度快剪(平均镜头${(durs(board)).toFixed(1)}s) + 强视觉奇观(拟人角色舞台) + 热歌情感嫁接
+
+---
+*由 skill-video-lapian/JSS-02 aigc_storyboard.mjs 自动生成*`;
+}
+const durs = (board) => board.length ? board.reduce((s, x) => s + x.dur, 0) / board.length : 0;
+
+// ---------- 主入口: 组装 aigc_remake.json + aigc_prompt.md ----------
+export async function generate(workdir) {
+  const fs = await import("node:fs");
+  const safe = (n) => { try { return JSON.parse(fs.readFileSync(`${workdir}/${n}`, "utf8")); } catch { return null; } };
+  const shots = safe("shots.json") || {};
+  const frames = safe("frames.json") || [];
+  const tr = safe("transcript.json") || {};
+  const segments = tr?.data?.segments || [];
+  const meta = safe("meta.json") || {};
+  const comments = safe("comments.json") || [];
+  const classify = safe("classify.json") || {};
+
+  // 段落结构优先级: 人工精修 sections.json > 歌词重复自动检测
+  const sections = safe("sections.json")?.sections || detectLyricStructure(segments);
+  const registry = extractRegistry(frames.map((f) => f.caption || "").concat((meta.desc || "").split(/[,。#\s]+/)));
+  const board = buildStoryboard({ shots: shots.shots || [], frames, segments, sections });
+  const lyricCov = segments.length ? board.filter((s) => s.lyric).length / Math.max(board.filter((s) => s.lyric !== "").length || board.length, 1) : 0;
+  const score = scoreVideo({ meta, shots: board, frames, lyricDensity: clamp(lyricCov, 0, 1) });
+
+  // 自动改进/复刻策略
+  const stats = meta.stats || {};
+  const spm = Math.round(board.length / ((shots.duration || 1) / 60));
+  const suggestions = [
+    `复刻要点: 保持 ${board.length} 镜/${spm}镜每分钟的高密度节奏, 展示镜头(≥4s)只留 ${board.filter((s) => s.dur >= 4).length} 个用作记忆点, 其余快切卡点`,
+    `角色一致性是 AIGC 成败关键: 全片主体(${(registry.characters || []).slice(0, 3).join("/")})必须逐镜回读锚点, 建议首镜生图后用图生视频保持形象`,
+    `音频驱动剪辑: 先定歌曲段落结构(本片 ${sections.length} 段), 镜头切换点对齐鼓点/段落边界, 而非自由分配`,
+    stats.share_count > stats.collect_count
+      ? `传播设计: 本片转发(${stats.share_count})远超收藏(${stats.collect_count}), 爆点在「社交谈资」(熟悉IP意外混搭) — 复刻时选认知度高的IP做反差组合`
+      : `传播设计: 收藏偏高说明「教程感/复用价值」强, 复刻时在结尾加可复用的模板感设计`,
+    `发布策略: 挂 #ai创作浪潮计划 等官方活动标签 + 音乐原声热歌, 是本赛道的流量杠杆`,
+  ];
+  const remake = {
+    video: { aweme_id: meta.aweme_id, desc: meta.desc, author: meta.author, duration_s: (shots.duration || 0), url: `https://www.douyin.com/video/${meta.aweme_id}` },
+    type: classify,
+    structure: { sections, shot_count: board.length, avg_shot_s: +(durs(board)).toFixed(2) },
+    registry,
+    storyboard: board,
+    score,
+    suggestions,
+    voc: { total: comments.length, top: comments.slice(0, 5).map((c) => ({ likes: c.likes, text: (c.text || "").slice(0, 60) })) },
+  };
+  fs.writeFileSync(`${workdir}/aigc_remake.json`, JSON.stringify(remake, null, 1));
+  fs.writeFileSync(`${workdir}/aigc_prompt.md`, buildLongPrompt({ meta, classify, registry, board, sections, score }));
+  return remake;
+}
+
+// CLI
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const wd = process.argv[2] || ".";
+  const r = await generate(wd);
+  console.log(`分镜 ${r.structure.shot_count} | 段落 ${r.structure.sections.length} | 综合 ${r.score.total} 分`);
+  console.log(`→ ${wd}/aigc_remake.json + ${wd}/aigc_prompt.md`);
+}

+ 135 - 0
dist/skills/fmode-video-lapian/js/type_classify.mjs

@@ -0,0 +1,135 @@
+/**
+ * type_classify.mjs — 短视频类型预判(JSS-01)
+ *
+ * 输入: meta(标题/标签/作者简介/时长/数据) + transcript(口播词, 可选) + sceneStats(镜头统计, 可选)
+ * 输出: { primary, secondary[], confidence, evidence[], mode }
+ *   mode = "lapian"(方法论文拉片) | "aigc_storyboard"(AIGC分镜仿制拉片)
+ *
+ * 设计原则: 特征打分制, 每类有 title/tags/pattern/vo/duration 五维证据;
+ * 无 LLM 依赖, 纯规则, 可在浏览器/Node 双端运行(纯 ESM, 零 import)。
+ */
+
+// ---- 短视频类型体系(2026-08 平台实践版) -------------------------------
+export const TYPES = {
+  talking_head: {
+    label: "真人口播", mode: "lapian",
+    patterns: ["口播", "观点", "干货", "认知", "思维", "老板", "创业", "商业", "财经", "成长", "IP", "操盘", "变现", "生意"],
+    voHints: ["我", "我们", "为什么", "其实", "本质", "认知", "普通人", "记住"],
+  },
+  aigc_music: {
+    label: "AIGC音乐现场", mode: "aigc_storyboard",
+    patterns: ["ai", "aigc", "翻唱", "演唱", "现场", "音乐", "MV", "演唱会", "歌手", "和声", "舞台", "音综", "选秀", "综艺", "封神"],
+    voHints: [],
+  },
+  aigc_shortdrama: {
+    label: "AIGC短剧", mode: "aigc_storyboard",
+    patterns: ["短剧", "剧情", "逆袭", "战神", "赘婿", "重生", "霸道", "复仇", "穿越", " ads ", "AI短剧"],
+    voHints: ["他", "她", "只听", "只见", "这时", "下一秒", "众人"],
+  },
+  aigc_animal: {
+    label: "AIGC动物拟人", mode: "aigc_storyboard",
+    patterns: ["动物", "猫咪", "狗", "熊猫", "蜘蛛", "蛇", "海洋", "森林", "拟人", "童话"],
+  },
+  vlog: {
+    label: "真人Vlog", mode: "lapian",
+    patterns: ["vlog", "日常", "记录", "探店", "旅行", "一天", "生活"],
+  },
+  drama: {
+    label: "真人剧情/段子", mode: "lapian",
+    patterns: ["剧情", "段子", "反转", "夫妻", "婆媳", "职场", "搞笑"],
+  },
+  food: {
+    label: "美食", mode: "lapian",
+    patterns: ["美食", "探店", "做法", "食谱", "菜谱", "吃播", "食材"],
+  },
+  knowledge: {
+    label: "知识科普", mode: "lapian",
+    patterns: ["科普", "知识", "历史", "物理", "宇宙", "解构", "解读", "解读"],
+  },
+};
+
+// 镜头节奏指纹(镜头/分钟): 口播≈5-15, 剧情叙事≈15-25, 音乐现场/卡点≈25-90
+export function rhythmScore(shotsPerMin) {
+  if (shotsPerMin >= 45) return { aigcLikely: true, label: "超高密度卡点(音乐/燃向)" };
+  if (shotsPerMin >= 22) return { aigcLikely: true, label: "高密度快剪(音乐现场/混剪)" };
+  if (shotsPerMin >= 12) return { aigcLikely: false, label: "中密度叙事" };
+  return { aigcLikely: false, label: "低密度口播/长take" };
+}
+
+export function classify({ meta = {}, transcript = "", sceneStats = {} } = {}) {
+  const title = `${meta.desc || ""} ${meta.author_signature || ""}`.toLowerCase();
+  const tags = (meta.tags || []).join(" ").toLowerCase();
+  const text = transcript.toLowerCase();
+  const evidence = [];
+  const scores = {};
+  for (const k of Object.keys(TYPES)) scores[k] = 0;
+
+  // 1) 标签强证据(权重3, 直接命中类型名或同义词)
+  for (const [key, def] of Object.entries(TYPES)) {
+    for (const p of def.patterns) {
+      const needle = p.toLowerCase().trim();
+      if (!needle) continue;
+      if (tags.includes(needle)) {
+        scores[key] += 3;
+        evidence.push(`标签命中[${def.label}]: #${needle}`);
+      } else if (title.includes(needle)) {
+        scores[key] += 2;
+        evidence.push(`标题/简介命中[${def.label}]: "${needle}"`);
+      }
+    }
+  }
+  // 2) 口播词特征(权重1)
+  if (text) {
+    for (const [key, def] of Object.entries(TYPES)) {
+      const hits = (def.voHints || []).filter((h) => text.includes(h)).length;
+      if (hits >= 2) {
+        scores[key] += 1;
+        evidence.push(`口播特征[${def.label}] ${hits}处`);
+      }
+    }
+  }
+  // 3) 镜头节奏指纹(权重2, aigc_storyboard 系共分)
+  if (sceneStats.shotsPerMin != null) {
+    const r = rhythmScore(sceneStats.shotsPerMin);
+    evidence.push(`节奏指纹: ${sceneStats.shotsPerMin}/分 → ${r.label}`);
+    if (r.aigcLikely) {
+      for (const k of ["aigc_music", "aigc_shortdrama", "aigc_animal"]) scores[k] += 2;
+    }
+  }
+  // 4) AIGC 水印/标签强制信号
+  const aigcMarkers = ["ai创作浪潮计划", "aigc", "ai绘画", "ai视频", "ai翻唱", "ai音乐", "即梦", "可灵", "vidu", "runway", "sora"];
+  for (const mk of aigcMarkers) {
+    if (tags.includes(mk) || title.includes(mk)) {
+      for (const k of ["aigc_music", "aigc_shortdrama", "aigc_animal"]) scores[k] += 4;
+      evidence.push(`AIGC强制信号: ${mk}`);
+      break;
+    }
+  }
+
+  const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]).filter(([, v]) => v > 0);
+  const primary = ranked[0]?.[0] || "talking_head";
+  const confidence = ranked.length
+    ? Math.min(0.99, ranked[0][1] / Math.max(1, ranked.slice(0, 2).reduce((s, [, v]) => s + v, 0)))
+    : 0.3;
+  const def = TYPES[primary];
+  return {
+    primary, primaryLabel: def.label, mode: def.mode,
+    secondary: ranked.slice(1, 3).map(([k]) => k),
+    scores: ranked, confidence: Math.round(confidence * 100) / 100,
+    evidence: [...new Set(evidence)].slice(0, 12),
+  };
+}
+
+// CLI: node type_classify.mjs workdir
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const fs = await import("node:fs");
+  const wd = process.argv[2] || ".";
+  const safeJSON = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; } };
+  const meta = safeJSON(`${wd}/meta.json`);
+  const transcript = safeJSON(`${wd}/transcript.json`)?.data?.text || "";
+  const s = safeJSON(`${wd}/shots.json`);
+  const sceneStats = s.shot_count ? { shotsPerMin: Math.round(s.shot_count / (s.duration / 60)) } : {};
+  const r = classify({ meta, transcript, sceneStats });
+  console.log(JSON.stringify(r, null, 2));
+  fs.writeFileSync(`${wd}/classify.json`, JSON.stringify(r, null, 2));
+}

+ 74 - 0
dist/skills/fmode-video-lapian/references/methodology.md

@@ -0,0 +1,74 @@
+# 拉片方法论(编导知识库要点 · 分析分段时对照使用)
+
+> 提炼自 ${FMODE_DATA_DIR:-$HOME}/knowledge-base/编导/ 下的编导知识库 (示例来源: 薛辉/郑经说/三把刀/编导小希/南门录像厅)。
+> 逐段点评时: 每段至少对上 1 条技法, 用「导师·技法」格式标注。
+
+## 一、薛辉小清新(短视频编导/IP 体系头部, 285万粉)
+
+| 技法 | 要点 | 识别特征 |
+|---|---|---|
+| 观点前置 | 第一句抛反常识观点, 停住人 | 开头即下判断, 无铺垫 |
+| 排比否定 | "既不…又不…也不…" 制造节奏 | 连续否定式排比 |
+| 不是A, 是B | 核心转折句式, 重定义 | "不是没X, 只是X都在Y里" |
+| 反击句式 | "有人说这是懒, 我觉得这是清醒" | 有人说X→我觉得Y |
+| 画面感细节 | 泉水不要钱/风免费吹/5块钱野花 | 具体到价格、动作、物件 |
+| 置景决定粉丝画像 | 背景直接决定吸什么人群 | 评价画面置景与目标人群匹配度 |
+| 收束升华 | 结尾回扣开头, 更高维定义 | "可能不是X, 而是Y" |
+| 系统化素材库 | 努力(搭系统) > 吃苦(蛮干) | 案例密度高, 信手拈来 |
+
+## 二、郑经说(功利型内容)
+
+| 技法 | 要点 |
+|---|---|
+| 人群分层 | 傻子/聪明人双轨: 流量款博泛人群, 付费款讲痛点 |
+| 功利型内容 | 教用户以极低成本获取极高收益, 天然有流量 |
+| 内容四原则 | 我相信/我想讲/我能想清楚/我能讲精炼——不迷信SOP |
+| 赚钱圣体 | 执行力→直播, 思考力→短视频(平台匹配) |
+
+## 三、三把刀(口播/成交文案, 86万粉)
+
+| 技法 | 要点 |
+|---|---|
+| 流量文案 vs 变现文案 | 流量讲观点(反认知), 变现讲痛点(迫切+解法) |
+| 平台基因 | 抖音=贪嗔痴算法; 视频号=真善美明牌 |
+| 痛点三段式 | 讲痛点→证明能解决→引导转化 |
+
+## 四、编导小希(操盘手, 前字节巨量课堂)
+
+| 技法 | 要点 |
+|---|---|
+| 前三秒画面钩子 | 钩子不一定来自第一句, 可以是第一个画面(猎奇+讲故事+期待感) |
+| 运动=流量密码 | 人物+运动开场, 完播率提升 |
+| 赛道决定上限 | 剧情/颜值类创作强但收入有天花板 |
+
+## 五、南门录像厅(剪辑思维, 126万粉)
+
+| 技法 | 要点 |
+|---|---|
+| 升格镜头 | 片子太平→慢动作制造节奏起伏 |
+| 素材资源库 | 剪辑最痛苦的是找素材, 建库解决 |
+
+## 六、逐段分析模板(报告 LLM 提示词的依据)
+
+对每个时间段检查 5 件事:
+1. **钩子力度** — 第一句/第一个画面是否停住人(小希: 画面钩子优先)
+2. **节奏控制** — 排比/对比/递进/升格(薛辉+南门)
+3. **画面匹配度** — 台词与置景/表情/动作是否互证(薛辉置景论)
+4. **情绪价值** — 共鸣/争议/认同(郑经说人群分层)
+5. **技法标签** — 用上表「导师·技法」命名
+
+## 七、评论区 VOC 分析维度
+
+- 分类: 认同 / 争议 / 补充 / 提问(三把刀: 认同是流量, 痛点是选题)
+- 高赞评论 = 免费的第二文案(可沉淀为下一条口播稿素材)
+- 最有共鸣的具体意象(如"社保交满就回云南") = 下一条视频的画面锚点
+
+## 八、结构评判基准(起承转合)
+
+| 段 | 功能 | 合格线 |
+|---|---|---|
+| 起(0-3s) | 观点/画面钩子 | 反常识 or 强画面 |
+| 承 | 解释+具体细节 | 至少2个画面感细节 |
+| 转 | 价值观对比/冲突 | 有明确对立面 |
+| 合 | 场景铺陈 | 情绪推到最高 |
+| 收 | 回扣+升华 | 闭环结构 |

+ 73 - 0
dist/skills/fmode-video-lapian/references/s3-upload.md

@@ -0,0 +1,73 @@
+# S3 个人空间发布规范(存储铁律见 feme-identity-storage 技能)
+
+## 桶与路径
+
+```
+桶:   storage-s3-nkkj (华为云 OBS, cn-north-4)
+<<<<<<< HEAD
+域名: 【铁律 2026-08-29】公开链接一律 https://s3.fmode.cn/<key> (全小写 Host)
+      链路: s3.fmode.cn(CDN) -> 回源 storage-s3-nkkj.obs-website.cn-north-4
+            (OBS 静态网站托管) + CDN 响应头 Content-Disposition: inline
+      桶本身不渲染网页, 必须经 CDN 才能静态渲染; OBS 原生域名只用于签名上传/内部校验
+=======
+对外分发域名(硬性): https://s3.fmode.cn/<key>
+      (s3.fmode.cn 反代已生效, 报告相对路径资源只有经它才能被浏览器正常加载;
+       OBS 原生域名 storage-s3-nkkj.obs.cn-north-4.myhuaweicloud.com 仅用于
+       上传/签名协议层, 严禁出现在发给用户的链接里)
+>>>>>>> d65e9489096639272bb3e0ecf63c77681fe7c2b8
+铁律: 只能写 user/<我的userid>/ 前缀; 他人前缀 = 越权
+```
+
+## 拉片产物路径(硬性, 全英文小写)
+
+```
+user/<userid>/report/lapian/<平台简称>/<YYYYMMDD>/
+├── report.html           # 报告本体(入口)
+├── video.mp4             # 原视频
+├── audio.wav             # 音频
+├── transcript.json       # 带时间戳转写
+├── frames/f0001.jpg...   # 抽帧切片
+└── assets/               # 报告内嵌资源(analysis.json/comments.json/meta.json)
+```
+
+- 平台简称: douyin / xiaohongshu / weixin / bilibili / tiktok / kuaishou / shortdrama
+- 同一条视频重跑: 直接覆盖同日期目录(幂等); 换版本建议换日期目录
+
+## 相对路径(核心)
+
+报告 HTML 里所有资源引用一律**相对路径**:
+```html
+<source src="video.mp4">          <!-- 不是 https://... 绝对地址 -->
+<img src="frames/f0001.jpg">
+```
+→ 整个前缀自包含, s3.fmode.cn 生效/换桶/镜像到本地, 报告零改动可用。
+
+## 凭证策略(动态化)
+
+```
+优先: POST <FEME_STUDIO_URL>/api/storage/credentials
+      Header: Authorization: Bearer <FEME_SESSION_TOKEN>
+      Body:  {"prefix": "user/<userid>/report/lapian/"}
+      → {access, secret, securityToken, expires}  (限 prefix, 1小时)
+过渡: studio credentials API 未上线时(2026-08 实测 404),
+      回退容器级 CLOUD_SDK_AK / CLOUD_SDK_SK —— 仍只写自己 user/<userid>/ 前缀
+```
+`scripts/common.py get_storage_credentials()` 已封装该回退逻辑, 上线后零改动切换。
+
+## 上传技术要点(实测踩坑)
+
+1. **OBS 必须 virtual-hosted addressing**: bucket 放进 Host
+   (`storage-s3-nkkj.obs.cn-north-4.myhuaweicloud.com`),
+   path-style(把 bucket 放 URL 路径)会报 `VirtualHostDomainRequired`
+2. SigV4 签名: service=`s3`, region=`cn-north-4`,
+   头部至少签 `host;x-amz-content-sha256;x-amz-date`(临时凭证再加 x-amz-security-token)
+3. 公开读: 每个对象 PUT 时带 `x-amz-acl: public-read`(obsutil 老版没有 setacl,
+   boto3 需 `Config(s3={"addressing_style": "virtual"})`)
+4. Content-Type 要显式: html=`text/html; charset=utf-8`, mp4=`video/mp4`,
+   wav=`audio/wav`, jpg=`image/jpeg` —— 不然浏览器直接下载而非渲染
+5. 上传完对**每个**产物做 HEAD 校验(200 才算成功), publish.py 已内置
+6. 二进制绝不进 git(S3 只存产物, git 仓库只存代码/模板/文档)
+
+## 成本参考(2026-08 实测)
+
+1 条 1 分钟视频整套产物 ≈ 25MB(视频23MB+音频2MB+帧+html), OBS 流量费忽略不计。

+ 64 - 0
dist/skills/fmode-video-lapian/references/voc-api.md

@@ -0,0 +1,64 @@
+# VOC 采集接口(经 server.fmode.cn 网关, 雨飏001容器 2026-08-28 实测可用)
+
+## 网关与鉴权
+
+```
+网关: https://server.fmode.cn/api/voc-social/<proxyPath>
+鉴权: Authorization: Bearer <FEME_NEWAPI_TOKEN>   # sk- 开头的 API 计费 token
+备选: x-api-key / body.token / query ?token=      # 服务端三通道都认, Bearer 最稳
+计费: 走 newapi 账套(每个生命自己的 token, 消耗自己余额)
+```
+
+注意:
+- 响应层级: **`data.aweme_list` / `data.aweme_detail`**, 不是 `data.data.aweme_list`
+- 错误分类: 401=token 无效(检查是否漏了 Bearer), 402/403=余额/权限, 429=风控限速(退避重试)
+- 所有请求带浏览器 UA; 平台风控时会 5xx, 客户端做 3 次指数退避
+
+## 抖音接口(已验证)
+
+### 1. 视频详情 — `douyin/web/fetch_one_video` (比 app/v3 可靠, 首选)
+```
+GET douyin/web/fetch_one_video?aweme_id=<视频ID>
+→ data.aweme_detail.{desc, author.nickname, author.signature, statistics,
+                      video.play_addr.url_list[], video.download_addr.url_list[],
+                      duration(ms), create_time, text_extra[].hashtag_name}
+```
+- 播放地址优先 `play_addr.url_list[0]`, 备用 `download_addr.url_list[0]`
+- CDN 带时效签名, 拿到就下, 别存 URL 过夜
+
+### 2. 搜索 — `douyin/app/v3/fetch_general_search`
+```
+GET douyin/app/v3/fetch_general_search?keyword=<关键词>&cursor=0
+→ data.data[] 或 data.aweme_list[], 每项含 aweme_id / desc / author / statistics
+```
+
+### 3. 评论 — `douyin/app/v3/fetch_video_comments`
+```
+GET douyin/app/v3/fetch_video_comments?aweme_id=<ID>&cursor=0&count=20
+→ data.comments[]{cid, text, digg_count, reply_comment_total,
+                   user.nickname, ip_label, create_time}
+翻页: cursor 累加返回条数; 返回空数组或 no more 即停
+```
+
+## 下载铁律
+- **完整下载**(流式读到底), 禁止 Range 截断 —— 拉片要全片
+- 下载后 `ffprobe` 验证时长与 meta.duration 一致
+- 命名: `<缓存目录>/<作者>/<aweme_id>.mp4`; 拉片工作目录用 `video.mp4`
+
+## 平台英文简称映射(S3 路径用)
+| 平台 | 简称 |
+|---|---|
+| 抖音 | douyin |
+| 小红书 | xiaohongshu |
+| 微信/视频号 | weixin |
+| B站 | bilibili |
+| TikTok | tiktok |
+| 快手 | kuaishou |
+| 短剧 | shortdrama |
+
+## Python 调用样例
+```python
+from scripts.common import voc_call, aweme_summary, extract_aweme_detail
+resp = voc_call("douyin/web/fetch_one_video", {"aweme_id": "7677548065384877346"})
+meta = aweme_summary(extract_aweme_detail(resp))   # desc/author/stats/play_urls
+```

BIN
dist/skills/fmode-video-lapian/scripts/__pycache__/common.cpython-313.pyc


+ 491 - 0
dist/skills/fmode-video-lapian/scripts/build_report.py

@@ -0,0 +1,491 @@
+#!/usr/bin/env python3
+"""build_report.py — 生成拉片 HTML 报告(暗色主题/手机自适应/相对路径).
+
+用法:
+  python3 build_report.py --workdir /tmp/lapian --title "拉片分析:听show丽江酒吧"
+前置(可缺失, 缺了自动降级):
+  meta.json / transcript.json / frames.json / comments.json
+产物: workdir/report.html + workdir/analysis.json
+特性:
+  - 分段: 优先 LLM(编导方法论点评), 失败回退规则启发式, 永不中断
+  - 词频/情绪: 纯本地 n-gram + 情绪词典(无第三方依赖)
+  - 模板渲染: 内置 mini-Jinja(stdlib), 无需安装 jinja2
+"""
+import argparse
+import datetime
+import json
+import os
+import re
+import sys
+import urllib.request
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from common import e as esc, fmt_ts, get_api_token  # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+TEMPLATE = os.path.join(ROOT, "templates", "report.html.j2")
+TEXT_MODEL = os.environ.get("FEME_TEXT_MODEL", "deepseek-v4-flash")
+API_BASE = os.environ.get("FEME_API_BASE", "https://api.fmode.cn/v1")
+
+TAG_CLS = {"薛辉": "tag-blue", "郑经说": "tag-purple", "三把刀": "tag-green",
+           "小希": "tag-yellow", "南门": "tag-yellow", "志楠": "tag-green"}
+POS_KW = ("同感 真实 说得对 认同 共鸣 喜欢 爱 赞 通透 治愈 舒服 自由 幸福 羡慕 "
+          "向往 懂生活 回家 回云南 想去 泪目 豁达 乐观").split()
+NEG_KW = ("不认同 杠 胡说 假的 喷 讨厌 反对 无语 尬 难看 翻车 骗 贩卖焦虑 内卷 累 苦"
+          ).split()
+SENT_STOP = "的了是在和就有不人我都这也你们他们啊吧吗呢把被将还又再便而或如果所以因为但是"
+LLM_PROMPT = """你是短视频编导顾问(方法体系: 薛辉·观点前置/排比/画面感/收束升华; 郑经说·人群分层/功利型内容; 三把刀·流量文案vs变现文案; 编导小希·前三秒画面钩子)。对下面这条口播视频逐段拉片。
+
+视频数据: 时长{dur}s, 点赞{digg}, 评论{cmt}, 收藏{coll}, 分享{share}
+文案全文: {fulltext}
+
+逐段台词(带时间戳):
+{segment_lines}
+
+已抽帧画面参考:
+{frame_lines}
+
+严格输出 JSON(不要markdown围栏):
+{{"segments":[{{"i":0,"func":"起/承/转/合/收 之一","techniques":["导师·技法名"],"comment":"60-90字点评: 句式/节奏/画面匹配/情绪价值, 点名用了什么技法","reuse":"可直接套用的句式模板, 含{{占位符}}"}}],
+"suggestions":[{{"title":"建议标题","body":"40字内可执行动作"}}]}}
+要求: segments 数量与输入逐段台词一一对应; techniques 每段1-3个, 用"导师·技法"格式; suggestions 3条。"""
+
+
+# ----------------------------------------------------------- mini-Jinja ----
+_TOKEN = re.compile(r"({{.*?}}|{%.*?%})", re.S)
+
+
+def _lookup(val, key):
+    if isinstance(val, dict):
+        return val.get(key, "")
+    if isinstance(val, (list, tuple)):
+        if key == "length":
+            return len(val)
+        try:
+            return val[int(key)]
+        except (ValueError, IndexError):
+            return ""
+    return getattr(val, key, "")
+
+
+def _resolve(expr, ctx):
+    expr = expr.strip()
+    if re.fullmatch(r"-?\d+(\.\d+)?", expr):
+        return float(expr)
+    if expr in ("true", "True"):
+        return True
+    if expr in ("false", "False", "none", "None"):
+        return False
+    parts = expr.split(".")
+    val = ctx.get(parts[0], "")
+    for p in parts[1:]:
+        val = _lookup(val, p)
+    return val
+
+
+def _parse(tokens, i):
+    nodes = []
+    while i < len(tokens):
+        t = tokens[i]
+        if t.startswith("{%"):
+            stmt = t[2:-2].strip()
+            kw, _, rest = stmt.partition(" ")
+            if kw in ("endif", "endfor", "else"):
+                return nodes, i
+            if kw == "if":
+                branches, cond = [], rest.strip()
+                while True:
+                    body, i = _parse(tokens, i + 1)
+                    branches.append((cond, body))
+                    tk = tokens[i]
+                    tstmt = tk[2:-2].strip()
+                    tkw, _, trest = tstmt.partition(" ")
+                    if tkw == "elif":
+                        cond = trest.strip()
+                        continue
+                    if tkw == "else":
+                        body, i = _parse(tokens, i + 1)
+                        branches.append((None, body))
+                    i += 1  # consume endif
+                    break
+                nodes.append(("if", branches))
+            elif kw == "for":
+                var, itexpr = rest.strip().split(" in ", 1)
+                body, i = _parse(tokens, i + 1)
+                i += 1  # consume endfor
+                nodes.append(("for", var.strip(), itexpr.strip(), body))
+            else:
+                i += 1
+        elif t.startswith("{{"):
+            nodes.append(("expr", t[2:-2].strip()))
+            i += 1
+        else:
+            nodes.append(("text", t))
+            i += 1
+    return nodes, i
+
+
+def _exec(nodes, ctx, out):
+    for node in nodes:
+        if node[0] == "text":
+            out.append(node[1])
+        elif node[0] == "expr":
+            v = _resolve(node[1], ctx)
+            out.append("" if v is None else str(v))
+        elif node[0] == "if":
+            for cond, body in node[1]:
+                if cond is None or _resolve(cond, ctx):
+                    _exec(body, ctx, out)
+                    break
+        elif node[0] == "for":
+            _, var, itexpr, body = node
+            it = _resolve(itexpr, ctx)
+            items = it if isinstance(it, (list, tuple)) else ([it] if it else [])
+            n = len(items)
+            for idx, item in enumerate(items, 1):
+                sub = dict(ctx)
+                sub[var] = item
+                sub["loop"] = {"index": idx, "first": idx == 1, "last": idx == n}
+                _exec(body, sub, out)
+
+
+def render_template(template: str, ctx: dict) -> str:
+    tokens = _TOKEN.split(template)
+    nodes, _ = _parse(tokens, 0)
+    out = []
+    _exec(nodes, ctx, out)
+    return "".join(out)
+
+
+# ------------------------------------------------------------- analysis ----
+def load_json(path):
+    if os.path.exists(path):
+        try:
+            return json.load(open(path, encoding="utf-8"))
+        except Exception:
+            return None
+    return None
+
+
+def call_llm(prompt: str):
+    """调文本模型。注意: 账号内 deepseek-v4-flash 实际路由到 reasoning 模型
+    (实测 z-ai/glm-5.3-flash), reasoning 会吃 token 预算 → max_tokens 给足,
+    否则 content 为空。返回 (content, 实际路由模型名)。"""
+    payload = {"model": TEXT_MODEL, "temperature": 0.3, "max_tokens": 12000,
+               "messages": [{"role": "user", "content": prompt}]}
+    req = urllib.request.Request(
+        f"{API_BASE}/chat/completions", data=json.dumps(payload).encode(),
+        headers={"Content-Type": "application/json",
+                 "Authorization": f"Bearer {get_api_token()}"})
+    with urllib.request.urlopen(req, timeout=300) as r:
+        resp = json.loads(r.read())
+    content = (resp["choices"][0]["message"].get("content") or "").strip()
+    if not content:
+        raise ValueError("模型返回空content(reasoning吃满token预算)")
+    return content, resp.get("model") or TEXT_MODEL
+
+
+def parse_llm_json(text: str) -> dict:
+    text = text.strip()
+    if text.startswith("```"):
+        text = text.strip("`").lstrip("json").strip()
+    try:
+        return json.loads(text)
+    except Exception:
+        pass
+    if "{" in text and "}" in text:
+        try:
+            return json.loads(text[text.index("{"): text.rindex("}") + 1])
+        except Exception:
+            pass
+    return {}
+
+
+def tag_cls(label: str) -> str:
+    for k, cls in TAG_CLS.items():
+        if k in label:
+            return cls
+    return "tag-blue"
+
+
+def heuristic_techniques(i: int, n: int, text: str) -> list:
+    tags = []
+    if i == 0:
+        tags.append("薛辉·观点前置")
+        tags.append("小希·前三秒钩子")
+    if re.search(r"(既不|又不|也不|不急着|不是[^。,]*,?不是)", text):
+        tags.append("薛辉·排比")
+    if re.search(r"(有人说|不是.*而是|别人.*我们|他们.*我们)", text):
+        tags.append("薛辉·反击句式/对比")
+    if re.search(r"(块钱|免费|不要钱|吃|喝|买|菜市场|院子)", text):
+        tags.append("薛辉·画面感细节")
+    if i == n - 1:
+        tags.append("薛辉·收束升华")
+    return tags or ["志楠·节奏控制"]
+
+
+def fallback_comment(i, n, text) -> str:
+    if i == 0:
+        return "开场即抛出反常识观点, 承担前三秒留人任务; 句式短促, 信息密度高。"
+    if i == n - 1:
+        return "结尾回扣开头形成闭环, 用更高维度的定义完成价值升华。"
+    return "中段延续主线, 用具体细节支撑观点, 保持节奏与信息密度。"
+
+
+def chi_words(text: str, top: int = 8) -> list:
+    """无分词词典 → 2/3-gram 近似, 去停用字边, 去包含重复。"""
+    text = re.sub(r"[^\u4e00-\u9fff]", " ", text)
+    grams = {}
+    for w in text.split():
+        for n in (2, 3):
+            for k in range(len(w) - n + 1):
+                g = w[k:k + n]
+                grams[g] = grams.get(g, 0) + 1
+    cands = [(g, c) for g, c in grams.items()
+             if c >= 2 and g[0] not in SENT_STOP and g[-1] not in SENT_STOP]
+    cands.sort(key=lambda x: -x[1])
+    kept = []
+    for g, c in cands:
+        if not any(g in k for k, _ in kept):
+            kept.append((g, c))
+        if len(kept) >= top:
+            break
+    mx = kept[0][1] if kept else 1
+    return [{"word": g, "count": c, "pct": max(6, round(c / mx * 100))}
+            for g, c in kept]
+
+
+def sentiment_split(text: str) -> list:
+    sents = [s for s in re.split(r"[。!?;\n]", text) if s.strip()]
+    pos = sum(1 for s in sents if any(k in s for k in POS_KW))
+    neg = sum(1 for s in sents if any(k in s for k in NEG_KW))
+    neu = max(len(sents) - pos - neg, 0)
+    tot = max(pos + neg + neu, 1)
+    rows = [("正面", pos, "var(--green)"), ("中性", neu, "var(--yellow)"),
+            ("负面", neg, "var(--red)")]
+    out, acc = [], 0
+    for i, (label, v, color) in enumerate(rows):
+        pct = round(v / tot * 100) if i < 2 else max(0, 100 - acc)
+        acc += pct
+        out.append({"label": label, "pct": pct, "color": color})
+    return out
+
+
+def comment_insight(text: str) -> str:
+    if any(k in text for k in ("回去", "回云南", "回家", "社保")):
+        return "在外游子的身份共鸣——最能带动转发"
+    if any(k in text for k in ("同感", "真实", "说得对", "就是这样")):
+        return "强认同型评论, 印证文案戳中人群"
+    if any(k in text for k in ("不是", "而是", "——")):
+        return "评论本身有文案意识, 可沉淀为选题素材"
+    if any(k in text for k in ("但", "不过", "其实")):
+        return "补充视角, 可作为下一条视频的回应点"
+    return "氛围型互动"
+
+
+def classify_comment(text: str) -> str:
+    if any(k in text for k in NEG_KW):
+        return "neg"
+    if any(k in text for k in POS_KW):
+        return "pos"
+    return "neu"
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--workdir", required=True)
+    ap.add_argument("--title", default="")
+    args = ap.parse_args()
+    wd = args.workdir
+
+    meta = load_json(os.path.join(wd, "meta.json")) or {}
+    tr = load_json(os.path.join(wd, "transcript.json")) or {}
+    frames = load_json(os.path.join(wd, "frames.json")) or []
+    comments = load_json(os.path.join(wd, "comments.json")) or []
+
+    data = tr.get("data") or {}
+    segs = data.get("segments") or []
+    fulltext = data.get("text") or "".join(s.get("text", "") for s in segs)
+    if not segs and fulltext:  # 无时间戳转写 → 整体一段
+        segs = [{"bg": 0, "ed": int(meta.get("duration_s", 60) * 1000),
+                 "speaker": "0", "text": fulltext}]
+    if not segs:
+        raise SystemExit("[report] 没有转写内容, 先跑 transcribe.py")
+    duration_s = meta.get("duration_s") or (segs[-1]["ed"] / 1000)
+
+    # --- LLM 逐段点评(失败回退启发式) ---
+    llm_engine, llm_out = "规则启发式", {}
+    manual = load_json(os.path.join(wd, "llm_out.json"))
+    if manual and manual.get("segments"):
+        llm_out, llm_engine = manual, "手动点评(llm_out.json)"
+        print(f"[report] 使用手动点评 {len(llm_out['segments'])} 段 (llm_out.json)")
+    else:
+        try:
+            seg_lines = "\n".join(
+                f"[{fmt_ts(s['bg'])}-{fmt_ts(s['ed'])}] {s.get('text','')}" for s in segs)
+            frame_lines = "\n".join(
+                f"{f['t_label']}: {f.get('caption','')}" for f in frames) or "(无)"
+            st = meta.get("stats") or {}
+            prompt = LLM_PROMPT.format(
+                dur=round(duration_s), digg=st.get("digg", "?"), cmt=st.get("comment", "?"),
+                coll=st.get("collect", "?"), share=st.get("share", "?"),
+                fulltext=fulltext[:1200], segment_lines=seg_lines, frame_lines=frame_lines)
+            raw, routed = call_llm(prompt)
+            llm_out = parse_llm_json(raw)
+            if llm_out.get("segments"):
+                llm_engine = f"{TEXT_MODEL}→{routed}"
+                print(f"[report] LLM 点评 {len(llm_out['segments'])} 段 ({llm_engine})")
+            else:
+                print("[report] LLM 输出无法解析, 回退规则启发式")
+        except Exception as ex:
+            print(f"[report] LLM 调用失败({ex}), 回退规则启发式")
+
+    llm_segs = {int(s.get("i", i)): s for i, s in enumerate(llm_out.get("segments") or [])}
+
+    # --- 组装分析段 ---
+    n = len(segs)
+    analysis_segs = []
+    for i, s in enumerate(segs):
+        t0, t1 = int(s.get("bg", 0)), int(s.get("ed", 0))
+        text = s.get("text", "")
+        L = llm_segs.get(i) or {}
+        techniques = [t for t in (L.get("techniques") or
+                                  heuristic_techniques(i, n, text)) if t]
+        frame = min(frames, key=lambda f: abs(f["t_ms"] - t0),
+                    default=None) if frames else None
+        if frame and abs(frame["t_ms"] - t0) > 8000:
+            frame = None
+        analysis_segs.append({
+            "i": i, "bg": t0, "ed": t1,
+            "t_start": fmt_ts(t0), "t_end": fmt_ts(t1), "t_start_s": t0 // 1000,
+            "text": esc(text), "preview": esc(text[:24] + ("…" if len(text) > 24 else "")),
+            "techniques": [{"label": esc(t), "cls": tag_cls(t)} for t in techniques],
+            "func": esc(L.get("func") or ""),
+            "comment_html": esc(L.get("comment") or fallback_comment(i, n, text)),
+            "reuse": esc(L.get("reuse") or ""),
+            "frame": frame,
+        })
+
+    # --- 整体结构表 ---
+    stage_names = ["起", "承", "转", "合", "收"]
+    structure_rows = []
+    for idx, sg in enumerate(analysis_segs):
+        stage = sg["func"] or stage_names[min(int(idx / n * len(stage_names)),
+                                              len(stage_names) - 1)]
+        structure_rows.append({
+            "stage": esc(stage), "time": f"{sg['t_start']}—{sg['t_end']}",
+            "func": esc(re.sub(r"^.{0,3}[::]", "", sg["func"]) if sg["func"]
+                        else ("开场留人" if idx == 0 else
+                              ("收束升华" if idx == n - 1 else "承接展开"))),
+            "techniques": " ".join(t["label"] for t in sg["techniques"]) or "—",
+        })
+    summary_html = esc(llm_out.get("structure_summary") or
+                       f"{n}段完成全片, 各段技法如上; 逐段细节见上方可折叠卡片。")
+
+    # --- 词频/情绪 ---
+    wf = chi_words(fulltext)
+    sentiment = sentiment_split(fulltext)
+    word_count = len(re.sub(r"\s", "", fulltext))
+    wpm = round(word_count / max(duration_s / 60, 0.1))
+
+    # --- 评论区 ---
+    comment_stats, top_comments, comment_keywords, comment_total = [], [], [], 0
+    if comments:
+        cls_counts = {"pos": 0, "neu": 0, "neg": 0}
+        for c in comments:
+            cls_counts[classify_comment(c.get("text", ""))] += 1
+        comment_total = len(comments)
+        rows = [("正面认同", cls_counts["pos"], "var(--green)"),
+                ("中性补充", cls_counts["neu"], "var(--yellow)"),
+                ("争议", cls_counts["neg"], "var(--red)")]
+        acc = 0
+        for i, (label, v, color) in enumerate(rows):
+            pct = round(v / comment_total * 100) if i < 2 else max(0, 100 - acc)
+            acc += pct
+            comment_stats.append({"label": label, "pct": pct, "color": color})
+        top_comments = [{"nickname": esc(c.get("nickname", "匿名")),
+                         "likes": c.get("likes", 0), "text": esc(c.get("text", "")),
+                         "insight": esc(comment_insight(c.get("text", "")))}
+                        for c in comments[:5]]
+        comment_keywords = chi_words(" ".join(c.get("text", "") for c in comments), top=6)
+
+    # --- 数据卡片 ---
+    st = meta.get("stats") or {}
+    stats_cards = [{"num": f"{duration_s:.0f}s", "label": "总时长"}]
+    if st.get("digg"):
+        stats_cards += [{"num": f"{st['digg']:,}", "label": "👍 点赞"},
+                        {"num": f"{st['comment']:,}", "label": "💬 评论"},
+                        {"num": f"{st['collect']:,}", "label": "🔖 收藏"},
+                        {"num": f"{st['share']:,}", "label": "↗ 分享"},
+                        {"num": f"{st['digg'] / max(st['comment'], 1):.0f}:1",
+                         "label": "赞评比"}]
+    else:
+        stats_cards += [{"num": len(comments), "label": "评论采集"},
+                        {"num": len(frames), "label": "抽帧"},
+                        {"num": n, "label": "分段"},
+                        {"num": word_count, "label": "总字数"},
+                        {"num": wpm, "label": "字/分"}]
+
+    # --- 建议 ---
+    suggestions = [{"title": esc(s.get("title", f"建议{ i + 1 }")),
+                    "body": esc(s.get("body", ""))}
+                   for i, s in enumerate(llm_out.get("suggestions") or [])]
+    if not suggestions:
+        hw = wf[0]["word"] if wf else "核心词"
+        pos_pct = next((r["pct"] for r in (comment_stats or sentiment)
+                        if r["label"] in ("正面认同", "正面")), 0)
+        suggestions = [
+            {"title": "强化记忆锚点", "body": f"高频词「{hw}」贯穿全片, 封面/标题/评论区置顶都应重复它, 形成账号记忆点。"},
+            {"title": "延续情绪线", "body": f"正面情绪约{pos_pct}%, 下一条保持同一情绪基调并升级一个具体场景细节。"},
+            {"title": "补强行动指令", "body": "结尾闭环后加一句轻互动引导(如「你家乡呢?」), 把共鸣转化为评论量。"},
+        ]
+
+    # --- 汇总上下文(所有动态值已转义) ---
+    author = meta.get("author") or "本地视频"
+    title = args.title or f"拉片分析:{author}"
+    today = datetime.date.today().strftime("%Y%m%d")
+    aweme_id = str(meta.get("aweme_id") or "local")
+    ctx = {
+        "meta": {
+            "title": esc(title),
+            "subtitle": esc(f"{(meta.get('desc') or '')[:42]} · {duration_s:.0f}秒 · "
+                            f"{n}段 · 方法论对照拆解"),
+            "author": esc(author), "author_signature": esc(meta.get("author_signature", "")),
+            "tags": [esc(t) for t in (meta.get("tags") or [])],
+            "publish_time": esc(meta.get("create_time", "")),
+            "aweme_id": esc(aweme_id), "platform": esc(meta.get("platform", "douyin")),
+            "generated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
+            "llm_engine": esc(llm_engine),
+            "report_id": esc(f"lapian-{meta.get('platform','douyin')}-{today}-{aweme_id[:10]}"),
+            "word_count": word_count, "wpm": wpm,
+            "sentence_count": len([s for s in re.split(r"[。!?]", fulltext) if s.strip()]),
+        },
+        "stats_cards": stats_cards,
+        "segments": analysis_segs,
+        "structure_rows": structure_rows,
+        "summary_html": summary_html,
+        "wordfreq": wf,
+        "sentiment": sentiment,
+        "comments": bool(comments),
+        "comment_total": comment_total,
+        "comment_stats": comment_stats,
+        "top_comments": top_comments,
+        "comment_keywords": comment_keywords,
+        "suggestions": suggestions,
+    }
+
+    html = render_template(open(TEMPLATE, encoding="utf-8").read(), ctx)
+    out_html = os.path.join(wd, "report.html")
+    open(out_html, "w", encoding="utf-8").write(html)
+    json.dump({"meta": {k: v for k, v in ctx["meta"].items()},
+               "segments": [{k: (v if k != "text" else v) for k, v in sg.items()
+                             if k not in ("frame",)} for sg in analysis_segs],
+               "suggestions": suggestions,
+               "llm_engine": llm_engine},
+              open(os.path.join(wd, "analysis.json"), "w", encoding="utf-8"),
+              ensure_ascii=False, indent=2, default=str)
+    print(f"[report] {out_html} ({os.path.getsize(out_html)/1024:.0f} KB)")
+
+
+if __name__ == "__main__":
+    main()

+ 427 - 0
dist/skills/fmode-video-lapian/scripts/common.py

@@ -0,0 +1,427 @@
+#!/usr/bin/env python3
+"""skill-video-lapian 共享库: 动态身份参数 + VOC网关 + S3 SigV4 上传.
+
+铁律: 所有凭据/端点从环境或 fmode-identity.json 读取, 绝不硬编码。
+每个数字生命用自己的 FEME_USERID / FEME_SESSION_TOKEN / FEME_NEWAPI_TOKEN。
+"""
+import datetime
+import hashlib
+import hmac
+import json
+import os
+import re
+import ssl
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+
+UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+      "(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
+
+_IDENTITY_CACHE = None
+
+
+# ---------------------------------------------------------------- identity --
+def _load_identity_file():
+    """读取 ${FMODE_DATA_DIR}/fmode-identity.json (或 $FEME_IDENTITY_FILE)。"""
+    global _IDENTITY_CACHE
+    if _IDENTITY_CACHE is not None:
+        return _IDENTITY_CACHE
+    path = os.environ.get("FEME_IDENTITY_FILE", os.path.join(os.environ.get("FMODE_DATA_DIR", "/opt/data"), "fmode-identity.json"))
+    data = {}
+    if os.path.exists(path):
+        try:
+            data = json.load(open(path, encoding="utf-8"))
+        except Exception as e:  # 文件坏了不致命, 继续用 env
+            print(f"[common] WARN 解析 {path} 失败: {e}", file=sys.stderr)
+    _IDENTITY_CACHE = data
+    return data
+
+
+_TOKEN_SHAPES = {"newapi_token": ("sk-",), "session_token": ("r:",),
+                 "studio_url": ("http",), "userid": ("__ALNUM__",),
+                 "cloud_ak": ("__ALNUM__",), "cloud_sk": ("__ALNUM__",)}
+
+
+def _identity_get(*names, secret=False):
+    """按优先级取身份参数: env(FEME_*/兼容旧名) > config.yaml > fmode-identity.json。
+
+    安全设计:
+    - 值按形状校验(token 必须 sk-/r: 开头, userid 必须 objectId 形状) ——
+      部分宿主会对 env 值做脱敏或塞入无关值($PATH 等), 打码值/杂值一律当缺失回退
+    - config.yaml 兜底: 雨飏容器实测(2026-08-28 采集脚本走它成功调 VOC 网关)
+    """
+    key = names[0]
+    env_map = {
+        "userid": ["FEME_USERID"],
+        "session_token": ["FEME_SESSION_TOKEN", "FMODE_SESSION_TOKEN"],
+        "newapi_token": ["FEME_NEWAPI_TOKEN", "FMODE_API_KEY", "FMODE_API_TOKEN"],
+        "studio_url": ["FEME_STUDIO_URL"],
+        "cloud_ak": ["CLOUD_SDK_AK"],
+        "cloud_sk": ["CLOUD_SDK_SK"],
+    }
+    shapes = _TOKEN_SHAPES.get(key, ())
+    ident = _load_identity_file()
+
+    def _ok(v, shape):
+        v = (v or "").strip()
+        if not v:
+            return None
+        if shape == "__ALNUM__":
+            # objectId/AK/SK 形状: 纯字母数字(挡 $PATH 等杂值与打码值)
+            return v if re.fullmatch(r"[A-Za-z0-9]{4,64}", v) else None
+        if shape and not v.startswith(shape):
+            return None
+        return v
+
+    for n in env_map.get(key, [names]):
+        v = _ok(os.environ.get(n), shapes[0] if shapes else ())
+        if v:
+            return v
+
+    if key == "newapi_token":
+        v = _ok(_token_from_config(), "sk-")
+        if v:
+            return v
+
+    ident_key = {"session_token": "session_token", "userid": "userid",
+                 "newapi_token": "newapi_token"}.get(key)
+    if ident_key:
+        v = _ok(ident.get(ident_key), shapes[0] if shapes else ())
+        if v:
+            return v
+
+    if key == "userid":
+        raise IdentityError(
+            "缺少身份参数 FEME_USERID。请 export FEME_USERID=<你的Parse objectId> "
+            "或确保 ${FMODE_DATA_DIR:-/opt/data}/fmode-identity.json 含 userid 字段 "
+            "(每个生命必须用自己的 userid, 禁止借用他人空间)。")
+    if key in ("cloud_ak", "cloud_sk"):
+        n = "CLOUD_SDK_AK" if key == "cloud_ak" else "CLOUD_SDK_SK"
+        v = (os.environ.get(n) or "").strip()
+        if re.fullmatch(r"[A-Za-z0-9]{16,}", v or ""):
+            return v
+    return ""
+
+
+def _token_from_config():
+    """从 ${FMODE_DATA_DIR}/config.yaml 读 api_key(含打码防护)。"""
+    global _CONFIG_TOKEN
+    if _CONFIG_TOKEN is not None:
+        return _CONFIG_TOKEN
+    _CONFIG_TOKEN = ""
+    path = os.environ.get("FEME_CONFIG_FILE", os.path.join(os.environ.get("FMODE_DATA_DIR", "/opt/data"), "config.yaml"))
+    if not os.path.exists(path):
+        return ""
+    try:
+        m = re.search(r'api_key:\s*["\']?(sk-[A-Za-z0-9_\-]+)', open(path, encoding="utf-8").read())
+        if m:
+            _CONFIG_TOKEN = m.group(1)
+    except Exception as e:
+        print(f"[common] WARN 读取 {path}: {e}", file=sys.stderr)
+    return _CONFIG_TOKEN
+
+
+_CONFIG_TOKEN = None
+
+
+def _camel(snake):
+    return "".join(p.capitalize() for p in snake.split("_"))
+
+
+class IdentityError(RuntimeError):
+    pass
+
+
+def get_userid() -> str:
+    uid = _identity_get("userid")
+    if not uid:
+        raise IdentityError(
+            "缺少身份参数 FEME_USERID (或 fmode-identity.json.userid)。"
+            "每个数字生命必须用自己的 userid, 禁止借用他人空间。")
+    return uid
+
+
+def get_session_token() -> str:
+    tok = _identity_get("session_token")
+    if not tok:
+        raise IdentityError("缺少 FEME_SESSION_TOKEN (飞马会话 r:...)")
+    return tok
+
+
+def get_api_token() -> str:
+    """API 计费 token (sk-..., 调 VOC 网关 / fmode-listen / 视觉模型都用它)。"""
+    tok = _identity_get("newapi_token")
+    if not tok:
+        raise IdentityError("缺少 FEME_NEWAPI_TOKEN (sk-...)")
+    return tok
+
+
+def get_studio_url() -> str:
+    """自己的 studio 端点 (storage/credentials 上线后用)。"""
+    url = _identity_get("studio_url")
+    if url:
+        return url.rstrip("/")
+    uid = get_userid()
+    # 雨飏001 端口约定; 其他生命容器请显式 export FEME_STUDIO_URL
+    return "https://server.fmode.cn:19001" if uid == "sr2WiPsDyQ" else ""
+
+
+def get_storage_credentials(prefix: str = "") -> dict:
+    """优先走 studio /api/storage/credentials (用户级临时凭证);
+    未上线(过渡期)回退容器级 CLOUD_SDK_AK/SK。返回 {access_key, secret_key}。"""
+    studio = get_studio_url()
+    if studio:
+        try:
+            body = json.dumps({"prefix": prefix}).encode()
+            req = urllib.request.Request(
+                f"{studio}/api/storage/credentials", data=body, method="POST",
+                headers={"Content-Type": "application/json",
+                         "Authorization": f"Bearer {get_session_token()}"})
+            resp = json.loads(urllib.request.urlopen(req, timeout=15).read())
+            cred = resp.get("data") or resp
+            if cred.get("access") and cred.get("secret"):
+                return {"access_key": cred["access"], "secret_key": cred["secret"],
+                        "security_token": cred.get("securityToken") or "", "source": "studio"}
+        except urllib.error.HTTPError as e:
+            if e.code != 404:
+                print(f"[common] WARN credentials API {e.code}, 回退容器 AK/SK", file=sys.stderr)
+        except Exception as e:
+            print(f"[common] WARN credentials API 不可用({e}), 回退容器 AK/SK", file=sys.stderr)
+    ak, sk = _identity_get("cloud_ak"), _identity_get("cloud_sk")
+    if ak and sk:
+        return {"access_key": ak, "secret_key": sk, "security_token": "", "source": "container-env"}
+    raise IdentityError("拿不到 S3 凭证: studio credentials 不可用且无 CLOUD_SDK_AK/SK")
+
+
+# --------------------------------------------------------------- VOC gateway --
+def voc_call(proxy_path: str, params=None, method="GET", retries=3):
+    """调 https://server.fmode.cn/api/voc-social/<proxyPath>, Bearer sk-token。
+
+    解析层级注意: data.aweme_list (不是 data.data.aweme_list)。
+    返回 json 或 {"error": ...}。
+    """
+    base = os.environ.get("VOC_SOCIAL_GATEWAY", "https://server.fmode.cn/api/voc-social")
+    url = f"{base}/{proxy_path.lstrip('/')}"
+    if params and method.upper() == "GET":
+        qs = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
+        if qs:
+            url += "?" + qs
+    headers = {"Authorization": f"Bearer {get_api_token()}",
+               "Accept": "application/json", "User-Agent": UA}
+    body = None
+    if method.upper() == "POST":
+        headers["Content-Type"] = "application/json"
+        body = json.dumps(params or {}).encode()
+    last = None
+    for attempt in range(retries):
+        req = urllib.request.Request(url, headers=headers, data=body,
+                                     method=method.upper())
+        try:
+            with urllib.request.urlopen(req, timeout=90) as r:
+                return json.loads(r.read().decode("utf-8", "replace"))
+        except urllib.error.HTTPError as e:
+            detail = e.read().decode("utf-8", "replace")[:300]
+            last = f"HTTP {e.code}: {detail}"
+            if e.code >= 500 and attempt < retries - 1:
+                continue
+            break
+        except Exception as e:
+            last = str(e)
+            if attempt < retries - 1:
+                continue
+            break
+    return {"error": last}
+
+
+def extract_aweme_detail(resp: dict) -> dict:
+    """从 fetch_one_video 响应提取 aweme 对象(容多种层级)。"""
+    data = resp.get("data") or {}
+    aweme = data.get("aweme_detail")
+    if not aweme:
+        lst = data.get("aweme_list") or []
+        aweme = lst[0] if lst else data
+    return aweme or {}
+
+
+def aweme_summary(aweme: dict) -> dict:
+    """提取报告需要的视频元数据。"""
+    st = aweme.get("statistics") or {}
+    author = aweme.get("author") or {}
+    video = aweme.get("video") or {}
+    play = (video.get("play_addr") or {}).get("url_list") or []
+    download = (video.get("download_addr") or {}).get("url_list") or []
+    tags = [t.get("hashtag_name") for t in (aweme.get("text_extra") or [])
+            if t.get("hashtag_name")]
+    ct = aweme.get("create_time")
+    return {
+        "aweme_id": aweme.get("aweme_id") or aweme.get("aweme_id_str") or "",
+        "desc": aweme.get("desc") or "",
+        "author": author.get("nickname") or "",
+        "author_signature": author.get("signature") or "",
+        "tags": tags,
+        "duration_ms": aweme.get("duration") or video.get("duration") or 0,
+        "create_time": datetime.datetime.fromtimestamp(ct).strftime("%Y-%m-%d %H:%M") if ct else "",
+        "stats": {
+            "digg": st.get("digg_count", 0), "comment": st.get("comment_count", 0),
+            "collect": st.get("collect_count", 0), "share": st.get("share_count", 0),
+            "play": st.get("play_count", 0),
+        },
+        "play_urls": play, "download_urls": download,
+    }
+
+
+# ------------------------------------------------------------------- ffmpeg --
+def ffprobe_duration(path: str) -> float:
+    import subprocess
+    out = subprocess.run(
+        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
+         "-of", "default=noprint_wrappers=1:nokey=1", str(path)],
+        capture_output=True, text=True)
+    try:
+        return float(out.stdout.strip())
+    except ValueError:
+        return 0.0
+
+
+# ------------------------------------------------------------- S3 SigV4 PUT --
+# OBS(华为云) 必须 virtual-hosted addressing: bucket 放进 Host。
+S3_BUCKET = os.environ.get("S3_BUCKET", "storage-s3-nkkj")
+S3_REGION = os.environ.get("S3_REGION", "cn-north-4")
+S3_HOST = os.environ.get("S3_ENDPOINT", "https://obs.cn-north-4.myhuaweicloud.com") \
+    .replace("https://", "").rstrip("/")
+# 公网访问铁律(2026-08-29 用户定调): 对外链接必须 s3.fmode.cn(全小写)。
+# 该域名走华为云CDN回源OBS静态网站托管(Content-Disposition:inline), OBS桶本身不渲染网页,
+# 直接用OBS原生域名会破坏网页渲染与统一域名。仅签名上传仍走 S3_UPLOAD_HOST。
+S3_PUBLIC_HOST = os.environ.get("S3_PUBLIC_HOST", "s3.fmode.cn")
+S3_UPLOAD_HOST = f"{S3_BUCKET}.{S3_HOST}"
+
+
+def _sigv4(method: str, key: str, payload: bytes, cred: dict,
+           content_type: str = "", query: str = "") -> dict:
+    """生成 OBS S3 兼容 SigV4 头。cred = get_storage_credentials()。"""
+    host = f"{S3_BUCKET}.{S3_HOST}"
+    t = datetime.datetime.now(datetime.timezone.utc)
+    amzdate = t.strftime("%Y%m%dT%H%M%SZ")
+    datestamp = t.strftime("%Y%m%d")
+    payload_hash = hashlib.sha256(payload).hexdigest()
+    headers = {"host": host, "x-amz-content-sha256": payload_hash, "x-amz-date": amzdate}
+    if content_type:
+        headers["content-type"] = content_type
+    if cred.get("security_token"):
+        headers["x-amz-security-token"] = cred["security_token"]
+    signed = ";".join(sorted(headers))
+    canonical_headers = "".join(f"{k}:{headers[k]}\n" for k in sorted(headers))
+    canonical = (f"{method}\n/{key}\n{query}\n{canonical_headers}\n{signed}\n{payload_hash}")
+    scope = f"{datestamp}/{S3_REGION}/s3/aws4_request"
+    sts = f"AWS4-HMAC-SHA256\n{amzdate}\n{scope}\n{hashlib.sha256(canonical.encode()).hexdigest()}"
+
+    def hm(k, m):
+        return hmac.new(k, m.encode(), hashlib.sha256).digest()
+
+    k = hm(hm(hm(hm(("AWS4" + cred["secret_key"]).encode(), datestamp), S3_REGION), "s3"),
+           "aws4_request")
+    sig = hmac.new(k, sts.encode(), hashlib.sha256).hexdigest()
+    auth = (f"AWS4-HMAC-SHA256 Credential={cred['access_key']}/{scope}, "
+            f"SignedHeaders={signed}, Signature={sig}")
+    out = {"Host": host, "Authorization": auth, "x-amz-date": amzdate,
+           "x-amz-content-sha256": payload_hash, "x-amz-acl": "public-read"}
+    if content_type:
+        out["Content-Type"] = content_type
+    if cred.get("security_token"):
+        out["x-amz-security-token"] = cred["security_token"]
+    return out
+
+
+def s3_put_bytes(key: str, payload: bytes, content_type: str = "application/octet-stream",
+                 public_read: bool = True) -> str:
+    """上传字节到 S3_BUCKET, 返回可公开访问的 URL(s3.fmode.cn, 见 S3_PUBLIC_HOST 铁律)。"""
+    cred = get_storage_credentials(f"user/{get_userid()}/")
+    hdrs = _sigv4("PUT", key, payload, cred, content_type)
+    if not public_read:
+        hdrs.pop("x-amz-acl", None)
+    url = f"https://{S3_UPLOAD_HOST}/{key}"
+    req = urllib.request.Request(url, data=payload, method="PUT", headers=hdrs)
+    with urllib.request.urlopen(req, timeout=300) as r:
+        if r.status not in (200, 201):
+            raise RuntimeError(f"S3 PUT {key} -> {r.status}")
+    return public_url(key)
+
+
+def s3_put_file(path: str, key: str, content_type: str = None) -> str:
+    ct = content_type or guess_content_type(path)
+    with open(path, "rb") as f:
+        return s3_put_bytes(key, f.read(), ct)
+
+
+def public_url(key: str) -> str:
+    """公开访问 URL。s3.fmode.cn 反代规范。
+    S3_PUBLIC_HOST 可覆盖; 旧 S3_PUBLIC_BASE 保持兼容。"""
+    if os.environ.get("S3_PUBLIC_BASE"):
+        return f"{os.environ['S3_PUBLIC_BASE'].rstrip('/')}/{key}"
+    host = os.environ.get("S3_PUBLIC_HOST", "s3.fmode.cn")
+    return f"https://{host}/{key}"
+
+
+def verify_public_host(key: str = "") -> bool:
+    """启动自检: s3.fmode.cn 反代可达。"""
+    url = f"https://s3.fmode.cn/{key or S3_BUCKET.replace('-s3','')}"
+    try:
+        req = urllib.request.Request(url, method="HEAD",
+                                     headers={"User-Agent": "Mozilla/5.0"})
+        with urllib.request.urlopen(req, timeout=12) as r:
+            ok = r.status < 500
+    except urllib.error.HTTPError as e:
+        ok = e.code < 500
+    except Exception as e:
+        ok = False
+        print(f"[common] WARN s3.fmode.cn 不可达({e}), 请检查 DNS/反代",
+              file=sys.stderr)
+    return ok
+
+
+def guess_content_type(path: str) -> str:
+    import mimetypes
+    return mimetypes.guess_type(path)[0] or "application/octet-stream"
+
+
+# ----------------------------------------------------------------- helpers --
+def fmt_ts(ms: float) -> str:
+    s = int(ms / 1000)
+    return f"{s // 60}:{s % 60:02d}"
+
+
+def slugify_platform(name: str) -> str:
+    """平台英文简称映射(douyin/xiaohongshu/weixin/bilibili/tiktok/shortdrama...)。"""
+    m = {"抖音": "douyin", "douyin": "douyin", "tiktok": "tiktok",
+         "小红书": "xiaohongshu", "xiaohongshu": "xiaohongshu", "rednote": "xiaohongshu",
+         "微信": "weixin", "weixin": "weixin", "wechat": "weixin", "视频号": "weixin",
+         "b站": "bilibili", "哔哩哔哩": "bilibili", "bilibili": "bilibili",
+         "快手": "kuaishou", "kuaishou": "kuaishou", "短剧": "shortdrama",
+         "shortdrama": "shortdrama"}
+    return m.get((name or "").strip().lower(), "douyin")
+
+
+def download_file(url: str, dest: str, min_size: int = 100_000) -> int:
+    """流式下载(完整, 不 Range 截断), 返回字节数。"""
+    os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
+    req = urllib.request.Request(url, headers={"User-Agent": UA})
+    total = 0
+    with urllib.request.urlopen(req, timeout=300) as r, open(dest, "wb") as f:
+        while True:
+            chunk = r.read(1 << 20)
+            if not chunk:
+                break
+            f.write(chunk)
+            total += len(chunk)
+    if total < min_size:
+        os.remove(dest)
+        raise RuntimeError(f"下载不完整({total}B < {min_size}B), 已删除 {dest}")
+    return total
+
+
+def e(text) -> str:
+    """HTML 转义(模板里用)。"""
+    from html import escape
+    return escape(str(text or ""), quote=True)

+ 63 - 0
dist/skills/fmode-video-lapian/scripts/fetch_comments.py

@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""fetch_comments.py — 采集评论区(可选, VOC 网关翻页).
+
+用法:
+  python3 fetch_comments.py --aweme-id 7677548065384877346 --workdir /tmp/lapian [--max 100]
+产物: workdir/comments.json = [{nickname,text,likes,...}...] (按点赞排序)
+"""
+import argparse
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from common import voc_call  # noqa: E402
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--aweme-id", required=True)
+    ap.add_argument("--workdir", required=True)
+    ap.add_argument("--max", type=int, default=100)
+    args = ap.parse_args()
+
+    comments, cursor, seen = [], 0, set()
+    while len(comments) < args.max:
+        resp = voc_call("douyin/app/v3/fetch_video_comments",
+                        {"aweme_id": args.aweme_id, "cursor": cursor, "count": 20})
+        if resp.get("error"):
+            print(f"[comments] 网关错误: {resp['error']}")
+            break
+        data = resp.get("data") or {}
+        batch = data.get("comments") or (data.get("data") or {}).get("comments") or []
+        if not batch:
+            break
+        new = 0
+        for c in batch:
+            cid = c.get("cid") or c.get("comment_id") or f"{c.get('user', {}).get('nickname')}|{c.get('create_time')}"
+            if cid in seen:
+                continue
+            seen.add(cid)
+            comments.append({
+                "nickname": (c.get("user") or {}).get("nickname") or "",
+                "text": c.get("text") or "",
+                "likes": c.get("digg_count") or 0,
+                "reply_count": c.get("reply_comment_total") or 0,
+                "time": c.get("create_time") or 0,
+                "ip_label": c.get("ip_label") or "",
+            })
+            new += 1
+        print(f"[comments] cursor={cursor} +{new} (累计 {len(comments)})")
+        cursor += len(batch)
+        if new == 0:
+            break
+
+    comments.sort(key=lambda x: -x["likes"])
+    out = os.path.join(args.workdir, "comments.json")
+    os.makedirs(args.workdir, exist_ok=True)
+    json.dump(comments, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
+    print(f"[comments] {len(comments)} 条 → {out}")
+
+
+if __name__ == "__main__":
+    main()

+ 156 - 0
dist/skills/fmode-video-lapian/scripts/fetch_video.py

@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""fetch_video.py — 拉片第一步: 拿到完整视频.
+
+用法:
+  # 抖音分享链接 / 视频ID → VOC 网关取详情+完整下载
+  python3 fetch_video.py --url "https://v.douyin.com/xxxx/" --out-dir /tmp/lapian
+  python3 fetch_video.py --aweme-id 7677548065384877346 --platform douyin --out-dir /tmp/lapian
+  # 已有本地视频 → 直接登记
+  python3 fetch_video.py --local /path/video.mp4 --out-dir /tmp/lapian --platform douyin
+
+产物: <out-dir>/video.mp4 (完整) + meta.json (作者/统计/标签/时长)
+铁律: 视频必须完整下载, 用 ffprobe 验证时长, 不许 Range 截断。
+"""
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from common import (aweme_summary, download_file, extract_aweme_detail,  # noqa: E402
+                    ffprobe_duration, get_api_token, slugify_platform, voc_call)
+
+
+def resolve_share_url(url: str) -> str:
+    """短链接 → 最终 URL(带重定向链)。"""
+    import urllib.request
+    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
+    with urllib.request.urlopen(req, timeout=30) as r:
+        return r.geturl()
+
+
+def extract_aweme_id(final_url: str) -> str:
+    # 抖音: /video/123 或 aweme_id=123
+    m = re.search(r"/(?:video|note)/(\d+)", final_url) or re.search(r"aweme_id=(\d+)", final_url)
+    if m:
+        return m.group(1)
+    # 微信视频号: channels/weixin/v/XXX 或 finder.video.qq.com 页面里的 wxv_xxx
+    m = re.search(r"wxv_(?:[A-Za-z0-9_\-]{10,})", final_url)
+    if m:
+        return m.group(0)
+    # 兜底: 任意 20+ 位纯数字(部分分享链把视频 id 放 query)
+    m = re.search(r"(\d{18,25})", final_url)
+    if m:
+        return m.group(1)
+    raise SystemExit(f"无法从 URL 提取视频ID: {final_url}")
+
+
+def detect_platform(url: str) -> str:
+    """按 URL 形态自动判平台(用户只贴链接不用选)。"""
+    u = url.lower()
+    if "douyin.com" in u or "iesdouyin.com" in u:
+        return "douyin"
+    if "channels.weixin" in u or "finder.video.qq.com" in u or "weixin" in u and "wxv" in u:
+        return "weixin"
+    if "xiaohongshu" in u or "xhslink" in u:
+        return "xiaohongshu"
+    if "bilibili" in u or "b23.tv" in u:
+        return "bilibili"
+    return "douyin"
+
+
+def fetch_meta(aweme_id: str, platform: str) -> dict:
+    """VOC 网关取视频详情。douyin=fetch_one_video; weixin=视频号通道。"""
+    if platform == "douyin":
+        resp = voc_call("douyin/web/fetch_one_video", {"aweme_id": aweme_id})
+        if resp.get("error"):
+            raise SystemExit(f"[fetch] VOC fetch_one_video 失败: {resp['error']}")
+        return aweme_summary(extract_aweme_detail(resp))
+    if platform == "weixin":
+        # 视频号: 先试专用通道,404/不支持时降级为直连下载(纯拉片不需要 meta 也能跑)
+        for proxy_path, params in (
+            ("weixin/channels/fetch_one_video", {"video_id": aweme_id, "aweme_id": aweme_id}),
+            ("weixin/channels/fetch", {"id": aweme_id}),
+        ):
+            try:
+                resp = voc_call(proxy_path, params)
+                if resp and not resp.get("error"):
+                    data = resp.get("data") or resp
+                    summary = {
+                        "video_id": aweme_id,
+                        "platform": "weixin",
+                        "title": (data.get("title") or data.get("desc") or "")[:120],
+                        "author": data.get("nickname") or data.get("author") or "",
+                        "play_urls": data.get("play_urls") or data.get("urls") or [],
+                        "download_urls": data.get("download_urls") or data.get("video_url") or [],
+                        "raw": {k: data.get(k) for k in ("duration", "cover_url") if data.get(k)},
+                    }
+                    print(f"[fetch] 视频号详情经 {proxy_path} 获取")
+                    return summary
+            except SystemExit:
+                continue
+            except Exception as exc:  # noqa: BLE001
+                print(f"[fetch] {proxy_path} 不可用: {str(exc)[:80]}")
+        print("[fetch] 视频号详情接口未命中, 降级直连模式(仅下载视频做拉片)")
+        return {}
+    print(f"[fetch] WARN 平台 {platform} 详情接口未接入, 仅下载/登记")
+    return {}
+
+
+def main():
+    ap = argparse.ArgumentParser(description="下载/登记待拉片视频")
+    ap.add_argument("--url", help="抖音/视频号分享短链或含视频ID的页面URL")
+    ap.add_argument("--aweme-id", help="视频ID")
+    ap.add_argument("--local", help="已有本地视频文件路径(跳过下载)")
+    ap.add_argument("--platform", default="auto",
+                    help="平台: auto(按URL自动判)/douyin/weixin/xiaohongshu/bilibili/tiktok/shortdrama")
+    ap.add_argument("--out-dir", required=True, help="工作目录(产物放这里)")
+    args = ap.parse_args()
+
+    os.makedirs(args.out_dir, exist_ok=True)
+    if args.platform == "auto":
+        src = args.url or ""
+        platform = detect_platform(src) if src else "douyin"
+        print(f"[fetch] 自动识别平台: {platform}")
+    else:
+        platform = slugify_platform(args.platform)
+    meta, video_path = {}, os.path.join(args.out_dir, "video.mp4")
+
+    if args.local:
+        video_path = os.path.abspath(args.local)
+        print(f"[fetch] 使用本地视频: {video_path}")
+    else:
+        if not (args.url or args.aweme_id):
+            ap.error("需要 --url / --aweme-id / --local 之一")
+        aweme_id = args.aweme_id
+        if args.url:
+            final = resolve_share_url(args.url)
+            aweme_id = extract_aweme_id(final)
+        print(f"[fetch] 视频ID: {aweme_id} (平台: {platform})")
+        meta = fetch_meta(aweme_id, platform)
+        urls = meta.get("play_urls") or meta.get("download_urls")
+        if not urls:
+            raise SystemExit("[fetch] 拿不到播放地址(可能视频被删/私密/风控)")
+        if os.path.exists(video_path) and ffprobe_duration(video_path) > 1:
+            print(f"[fetch] 复用已缓存 {video_path}")
+        else:
+            size = download_file(urls[0], video_path)
+            print(f"[fetch] 下载完成: {size/1e6:.1f} MB")
+        meta["aweme_id"] = meta.get("aweme_id") or aweme_id
+
+    dur = ffprobe_duration(video_path)
+    if dur <= 1:
+        raise SystemExit(f"[fetch] 视频时长异常({dur}s), 文件可能损坏")
+    print(f"[fetch] 时长验证通过: {dur:.1f}s")
+
+    meta.update({"video_path": os.path.abspath(video_path),
+                 "duration_s": round(dur, 2), "platform": platform})
+    meta_path = os.path.join(args.out_dir, "meta.json")
+    json.dump(meta, open(meta_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
+    print(f"[fetch] meta.json 已写入: 作者={meta.get('author')} 统计={meta.get('stats')}")
+
+
+if __name__ == "__main__":
+    main()

+ 146 - 0
dist/skills/fmode-video-lapian/scripts/frame_analysis.py

@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""frame_analysis.py — 抽帧 + 豆包视觉逐帧点评.
+
+用法:
+  python3 frame_analysis.py --workdir /tmp/lapian --fps 0.1 --max-frames 12
+  python3 frame_analysis.py --workdir /tmp/lapian --skip-vision   # 只抽帧, 不调视觉
+前置: workdir/video.mp4 (可选 workdir/transcript.json 用于对齐台词)
+产物: workdir/frames/f0001.jpg... + workdir/frames.json
+  frames.json = [{frame, file, t_ms, t_label, vision, caption}...]
+环境: FEME_NEWAPI_TOKEN — 调 api.fmode.cn 视觉模型(计费)
+"""
+import argparse
+import base64
+import json
+import os
+import subprocess
+import sys
+import urllib.request
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from common import ffprobe_duration, fmt_ts, get_api_token  # noqa: E402
+
+VISION_MODEL = os.environ.get("FEME_VISION_MODEL", "doubao-seed-2-0-pro-260215")
+VISION_BASE = os.environ.get("FEME_API_BASE", "https://api.fmode.cn/v1")
+
+PROMPT = """你是短视频编导顾问, 对视频抽帧做画面拆解。用中文 JSON 输出(不要markdown代码块):
+{"shot":"景别(远景/全景/中景/近景/特写)","scene":"场景与置景一句话",
+ "subjects":"人物/主体在做什么(表情动作)","text_on_screen":"画面文字, 没有则空串",
+ "aesthetic":"画面技法一句话(光影/构图/节奏)"},
+只描述这一帧可见的信息。"""
+
+
+def extract_frames(video: str, outdir: str, fps: float, max_frames: int) -> list:
+    """均匀抽帧(先抽满 fps, 超出上限再等距丢弃), 返回 [{frame,file,t_ms}]。"""
+    os.makedirs(outdir, exist_ok=True)
+    subprocess.run(["ffmpeg", "-y", "-i", video, "-vf", f"fps={fps}",
+                    "-q:v", "3", os.path.join(outdir, "f%04d.jpg")],
+                   capture_output=True, text=True)
+    files = sorted(f for f in os.listdir(outdir) if f.endswith(".jpg"))
+    if len(files) > max_frames:
+        keep = [files[round(i * (len(files) - 1) / (max_frames - 1))]
+                for i in range(max_frames)]
+        for f in files:
+            if f not in keep:
+                os.remove(os.path.join(outdir, f))
+        files = keep
+    dur = ffprobe_duration(video) or 0.0
+    frames = []
+    for i, f in enumerate(files):
+        t_ms = int(dur * 1000 * i / max(len(files) - 1, 1))
+        frames.append({"frame": i + 1, "file": f, "t_ms": t_ms,
+                       "t_label": fmt_ts(t_ms)})
+    return frames
+
+
+def nearest_line(t_ms: int, segments: list) -> str:
+    for seg in segments:
+        if seg.get("bg", 0) <= t_ms < seg.get("ed", 0):
+            return seg.get("text", "")
+    return ""
+
+
+def call_vision(image_path: str) -> str:
+    """单帧 → 豆包视觉, 返回模型文本(JSON字符串或自然语言)。"""
+    b64 = base64.b64encode(open(image_path, "rb").read()).decode()
+    payload = {
+        "model": VISION_MODEL,
+        "messages": [{"role": "user", "content": [
+            {"type": "text", "text": PROMPT},
+            {"type": "image_url",
+             "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}]}],
+        "temperature": 0.12, "max_tokens": 500,
+    }
+    req = urllib.request.Request(
+        f"{VISION_BASE}/chat/completions",
+        data=json.dumps(payload).encode(),
+        headers={"Content-Type": "application/json",
+                 "Authorization": f"Bearer {get_api_token()}"})
+    with urllib.request.urlopen(req, timeout=120) as r:
+        resp = json.loads(r.read())
+    return (resp["choices"][0]["message"]["content"] or "").strip()
+
+
+def parse_vision_json(text: str) -> dict:
+    """宽容解析模型输出(剥 markdown 围栏, 抠第一个 {...})。"""
+    text = text.strip()
+    if text.startswith("```"):
+        text = text.strip("`").lstrip("json").strip()
+    try:
+        return json.loads(text)
+    except Exception:
+        pass
+    if "{" in text and "}" in text:
+        try:
+            return json.loads(text[text.index("{"): text.rindex("}") + 1])
+        except Exception:
+            pass
+    return {}
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--workdir", required=True)
+    ap.add_argument("--fps", type=float, default=0.15, help="抽帧密度(默认0.15≈6.7s一帧)")
+    ap.add_argument("--max-frames", type=int, default=12, help="最多分析帧数(控制计费)")
+    ap.add_argument("--skip-vision", action="store_true", help="只抽帧, 不调视觉模型")
+    args = ap.parse_args()
+
+    video = os.path.join(args.workdir, "video.mp4")
+    if not os.path.exists(video):
+        raise SystemExit(f"[frames] 缺少 {video}")
+    outdir = os.path.join(args.workdir, "frames")
+    frames = extract_frames(video, outdir, args.fps, args.max_frames)
+    print(f"[frames] 抽帧 {len(frames)} 张 → {outdir}")
+
+    segs = []
+    tp = os.path.join(args.workdir, "transcript.json")
+    if os.path.exists(tp):
+        segs = (json.load(open(tp, encoding="utf-8")).get("data") or {}).get("segments") or []
+
+    for fr in frames:
+        fr["line"] = nearest_line(fr["t_ms"], segs)
+        if args.skip_vision:
+            fr["vision"] = {}
+            fr["caption"] = fr["line"][:60] or "(未调视觉)"
+            continue
+        img = os.path.join(outdir, fr["file"])
+        try:
+            raw = call_vision(img)
+            v = parse_vision_json(raw)
+            fr["vision"] = v
+            parts = [v.get(k, "") for k in ("scene", "subjects", "aesthetic")]
+            fr["caption"] = ";".join(p for p in parts if p) or raw[:120]
+            print(f"[frames] f{fr['frame']:04d} {fr['t_label']} ✓ {fr['caption'][:50]}")
+        except Exception as e:
+            fr["vision"], fr["caption"] = {}, f"(视觉识别失败: {e})"
+            print(f"[frames] f{fr['frame']:04d} {fr['t_label']} ✗ {e}")
+
+    out = os.path.join(args.workdir, "frames.json")
+    json.dump(frames, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
+    ok = sum(1 for f in frames if f.get("vision"))
+    print(f"[frames] frames.json: {len(frames)} 帧(视觉成功 {ok})")
+
+
+if __name__ == "__main__":
+    main()

+ 102 - 0
dist/skills/fmode-video-lapian/scripts/publish.py

@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""publish.py — 上传报告整套产物到 S3 个人空间, 生成公开URL.
+
+用法:
+  python3 publish.py --workdir /tmp/lapian
+路径规范(硬性, 全英文小写):
+  user/<userid>/report/lapian/<平台>/<YYYYMMDD>/
+    report.html · video.mp4 · audio.wav · transcript.json
+    frames/f0001.jpg... · assets/(analysis.json/comments.json/meta.json)
+报告内资源引用全部相对路径 → 整个前缀自包含, 换域名也能用。
+返回: report.html 的公开URL(并对每个产物做 HTTP 校验)。
+"""
+import argparse
+import datetime
+import json
+import os
+import sys
+import urllib.request
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from common import get_userid, public_url, s3_put_file, verify_public_host  # noqa: E402
+
+MANIFEST_CT = {
+    ".html": "text/html; charset=utf-8",
+    ".json": "application/json; charset=utf-8",
+    ".mp4": "video/mp4",
+    ".wav": "audio/wav",
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".png": "image/png",
+}
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--workdir", required=True)
+    ap.add_argument("--date", default="", help="覆盖日期(默认今天, YYYYMMDD)")
+    args = ap.parse_args()
+    wd = args.workdir
+
+    report = os.path.join(wd, "report.html")
+    if not os.path.exists(report):
+        raise SystemExit("[publish] 缺少 report.html, 先跑 build_report.py")
+    meta = (json.load(open(os.path.join(wd, "meta.json"), encoding="utf-8"))
+            if os.path.exists(os.path.join(wd, "meta.json")) else {})
+    platform = meta.get("platform") or "douyin"
+    userid = get_userid()
+    day = args.date or datetime.date.today().strftime("%Y%m%d")
+    base = f"user/{userid}/report/lapian/{platform}/{day}"
+    print(f"[publish] 目标前缀: {base}/  (identity userid={userid})")
+
+    uploads = []  # (local, key)
+    uploads.append((report, "report.html"))
+    for name in ("video.mp4", "audio.wav", "transcript.json"):
+        p = os.path.join(wd, name)
+        if os.path.exists(p):
+            uploads.append((p, name))
+    fdir = os.path.join(wd, "frames")
+    if os.path.isdir(fdir):
+        for f in sorted(os.listdir(fdir)):
+            if f.endswith(".jpg"):
+                uploads.append((os.path.join(fdir, f), f"frames/{f}"))
+    for name in ("analysis.json", "comments.json", "meta.json"):
+        p = os.path.join(wd, name)
+        if os.path.exists(p):
+            uploads.append((p, f"assets/{name}"))
+
+    urls = []
+    for local, rel in uploads:
+        key = f"{base}/{rel}"
+        ct = MANIFEST_CT.get(os.path.splitext(rel)[1].lower())
+        url = s3_put_file(local, key, ct)
+        urls.append((rel, url))
+        print(f"[publish] ✓ {rel} ({os.path.getsize(local)/1024:.0f} KB)")
+
+    # 逐个 HTTP 校验(外部状态回读, 不只信 PUT 返回) + 公开域名自检
+    verify_public_host()
+    fails = []
+    for rel, url in urls:
+        try:
+            req = urllib.request.Request(url, method="HEAD")
+            with urllib.request.urlopen(req, timeout=30) as r:
+                if r.status != 200:
+                    fails.append((rel, r.status))
+        except Exception as ex:
+            fails.append((rel, str(ex)[:60]))
+    if fails:
+        print("[publish] WARN 以下产物校验失败:", fails)
+    else:
+        print(f"[publish] 全部 {len(urls)} 个产物公开可访问 ✓")
+
+    report_url = public_url(f"{base}/report.html")
+    print("\n=== 公开访问 ===")
+    print(report_url)
+    json.dump({"report_url": report_url, "prefix": base,
+               "files": [{"name": rel, "url": u} for rel, u in urls]},
+              open(os.path.join(wd, "publish.json"), "w", encoding="utf-8"),
+              ensure_ascii=False, indent=2)
+
+
+if __name__ == "__main__":
+    main()

+ 114 - 0
dist/skills/fmode-video-lapian/scripts/publish_via_obs.py

@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""publish_via_obs.py — publish.py 的跳板机备用通道。
+
+适用场景: /api/storage/credentials 与 /api/storage/upload 均 404(未部署)、
+容器无 CLOUD_SDK_AK/SK、无 obsutil 时 —— 借道 server.fmode.cn(云服务器)上的
+obsutil(自带 nkkj 主账套 AK/SK)上传到 storage-s3-nkkj 桶。
+
+用法: python3 publish_via_obs.py --workdir /tmp/lapian [--date 20260829]
+产物清单与 publish.py 完全一致, 上传后逐个 HEAD 校验, 写 workdir/publish.json。
+"""
+import argparse, datetime, json, os, sys, tarfile, time, urllib.request
+import paramiko
+
+CLOUD = {"host": "139.159.253.131", "user": "root", "password": "bofang666."}
+OBSUTIL = "/opt/obsutil/obsutil_linux_amd64_5.4.11/obsutil"
+BUCKET = "storage-s3-nkkj"
+ENDPOINT = "obs.cn-north-4.myhuaweicloud.com"
+# 对外链接 host 规范(2026-08-30 硬性): 必须用 s3.fmode.cn, 禁用 OBS 原生域名
+# (浏览器只有经 s3.fmode.cn 反代才能正常加载报告的相对路径资源)
+PUB = "https://s3.fmode.cn"
+
+CT = {".html": "text/html; charset=utf-8", ".json": "application/json",
+      ".mp4": "video/mp4", ".wav": "audio/wav", ".jpg": "image/jpeg"}
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--workdir", required=True)
+    ap.add_argument("--date", default=datetime.date.today().strftime("%Y%m%d"))
+    ap.add_argument("--sub", default="", help="视频子目录(默认取meta.json的aweme_id, 防同日多视频覆盖)")
+    args = ap.parse_args()
+    wd = args.workdir
+    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+    from common import get_userid
+    userid = get_userid()
+    sub = args.sub
+    if not sub:
+        try:
+            sub = (json.load(open(f"{wd}/meta.json")) or {}).get("aweme_id") or "video"
+        except Exception:
+            sub = "video"
+    base = f"user/{userid}/report/lapian/douyin/{args.date}/{sub}"
+
+    # 打包清单(与 publish.py 一致)
+    files = ["report.html", "video.mp4", "audio.wav", "transcript.json"]
+    files = [f for f in files if os.path.exists(f"{wd}/{f}")]
+    files += [f"frames/{f}" for f in sorted(os.listdir(f"{wd}/frames"))] if os.path.isdir(f"{wd}/frames") else []
+    pkg = "/tmp/lapian-pkg.tar.gz"
+    asset_map = []
+    for n in ("analysis.json", "comments.json", "meta.json", "llm_out.json"):
+        if os.path.exists(f"{wd}/{n}"):
+            asset_map.append((f"{wd}/{n}", f"assets/{n}"))
+    with tarfile.open(pkg, "w:gz") as tf:
+        for rel in files:
+            tf.add(f"{wd}/{rel}", arcname=rel)
+        for local, arc in asset_map:
+            tf.add(local, arcname=arc)
+    files += [arc for _, arc in asset_map]
+    print(f"[pkg] {len(files)} files, {os.path.getsize(pkg)/1048576:.1f} MB")
+
+    # SFTP(带重试, banner 抖动常见)
+    ssh = paramiko.SSHClient()
+    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+    for attempt in range(4):
+        try:
+            ssh.connect(CLOUD["host"], 22, CLOUD["user"], CLOUD["password"],
+                        timeout=30, banner_timeout=30)
+            break
+        except Exception as ex:
+            print(f"[ssh] attempt {attempt+1} failed: {type(ex).__name__}, retry 15s")
+            time.sleep(15)
+    else:
+        sys.exit("[ssh] all retries failed")
+    sftp = ssh.open_sftp()
+    sftp.put(pkg, "/tmp/lapian-pkg.tar.gz")
+    sftp.close()
+
+    remote = f"""
+set -e
+rm -rf /tmp/lapian-pkg && mkdir -p /tmp/lapian-pkg
+tar -xzf /tmp/lapian-pkg.tar.gz -C /tmp/lapian-pkg
+cd /tmp/lapian-pkg
+for f in {' '.join(files)}; do
+  if {OBSUTIL} cp "$f" "obs://{BUCKET}/{base}/$f" -acl=public-read -e={ENDPOINT} -j=5 > /tmp/obs_last.log 2>&1; then
+    echo "UP OK $f"
+  else
+    echo "UP FAIL $f: $(tail -2 /tmp/obs_last.log)"
+  fi
+done"""
+    _, stdout, stderr = ssh.exec_command(remote, timeout=600)
+    out = stdout.read().decode(errors="replace")
+    print(out)
+    ssh.close()
+    fails = [l for l in out.splitlines() if "UP FAIL" in l]
+
+    # 公网校验
+    bad = []
+    for rel in files:
+        try:
+            req = urllib.request.Request(f"{PUB}/{base}/{rel}", method="HEAD")
+            with urllib.request.urlopen(req, timeout=25) as r:
+                if r.status != 200:
+                    bad.append((rel, r.status))
+        except Exception as ex:
+            bad.append((rel, str(ex)[:60]))
+    report_url = f"{PUB}/{base}/report.html"
+    print(f"\n[verify] {len(files)-len(bad)}/{len(files)} OK", f"fails={bad}" if bad else "ALL PASS")
+    json.dump({"report_url": report_url, "prefix": base, "fails": bad + fails},
+              open(f"{wd}/publish.json", "w"), ensure_ascii=False, indent=2)
+    print("REPORT URL:", report_url)
+
+
+if __name__ == "__main__":
+    main()

+ 72 - 0
dist/skills/fmode-video-lapian/scripts/transcribe.py

@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""transcribe.py — 提取音频并用 fmode-listen 转写(讯飞 LFASR, 分段时间戳).
+
+用法:
+  python3 transcribe.py --workdir /tmp/lapian
+前置: workdir/video.mp4 (fetch_video.py 产物)
+产物: workdir/audio.wav + workdir/transcript.json
+  transcript.json = {code, data:{text, segments:[{bg,ed,speaker,text}]}}, 毫秒
+环境: FEME_NEWAPI_TOKEN(或 FMODE_API_KEY) — 传给 fmode-listen 的 FMODE_API_TOKEN
+"""
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from common import get_api_token  # noqa: E402
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--workdir", required=True)
+    ap.add_argument("--language", default="autodialect",
+                    help="autodialect(中英+方言自动)/zh/en, 默认 autodialect")
+    args = ap.parse_args()
+
+    video = os.path.join(args.workdir, "video.mp4")
+    wav = os.path.join(args.workdir, "audio.wav")
+    out_json = os.path.join(args.workdir, "transcript.json")
+    if not os.path.exists(video):
+        raise SystemExit(f"[transcribe] 缺少 {video}, 先跑 fetch_video.py")
+
+    if not (os.path.exists(wav) and os.path.getsize(wav) > 1000):
+        print("[transcribe] 提取音频: 16kHz 单声道 wav")
+        r = subprocess.run(["ffmpeg", "-y", "-i", video, "-vn", "-ar", "16000",
+                            "-ac", "1", "-f", "wav", wav],
+                           capture_output=True, text=True)
+        if r.returncode != 0:
+            raise SystemExit(f"[transcribe] ffmpeg 失败: {r.stderr[-500:]}")
+    else:
+        print("[transcribe] 复用已提取的 audio.wav")
+
+    # 短路: 已有转写直接复用(转写计费, 别重复花钱)
+    if os.path.exists(out_json):
+        try:
+            old = json.load(open(out_json, encoding="utf-8"))
+            segs = (old.get("data") or {}).get("segments") or []
+            if segs:
+                print(f"[transcribe] 复用已有转写({len(segs)}段), 跳过计费")
+                return
+        except Exception:
+            pass
+
+    env = dict(os.environ, FMODE_API_TOKEN=get_api_token())
+    cmd = ["npx", "--yes", "fmode-listen@latest", "transcribe", "--", wav,
+           "--language", args.language, "--out", out_json]
+    print("[transcribe]", " ".join(cmd))
+    r = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=1800)
+    print(r.stdout[-800:])
+    if r.returncode != 0:
+        raise SystemExit(f"[transcribe] fmode-listen 失败: {r.stderr[-800:]}")
+    if not os.path.exists(out_json):
+        raise SystemExit("[transcribe] 转写产物缺失")
+
+    data = json.load(open(out_json, encoding="utf-8"))
+    segs = (data.get("data") or {}).get("segments") or []
+    print(f"[transcribe] 完成: {len(segs)} 段, 全文 {len((data.get('data') or {}).get('text',''))} 字")
+
+
+if __name__ == "__main__":
+    main()

+ 241 - 0
dist/skills/fmode-video-lapian/templates/report.html.j2

@@ -0,0 +1,241 @@
+<!DOCTYPE html>
+<html lang="zh-CN" data-theme="dark">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>{{ meta.title }} · 拉片报告</title>
+<style>
+  :root {
+    --bg:#0a0e17; --surface:#111827; --card:#1a2332; --border:#2a3a52;
+    --text:#e2e8f0; --muted:#94a3b8; --brand:#3b82f6; --brand-light:#60a5fa;
+    --green:#10b981; --yellow:#f59e0b; --red:#ef4444; --purple:#8b5cf6;
+  }
+  * { margin:0; padding:0; box-sizing:border-box; }
+  body { font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;
+         background:var(--bg); color:var(--text); line-height:1.7; padding-bottom:80px; }
+
+  /* === 顶部视频播放器(点击分段跳转对应时间) === */
+  .video-bar { position:sticky; top:0; z-index:100; background:#000;
+               border-bottom:1px solid var(--border); }
+  .video-bar video { width:100%; max-height:56vw; display:block;
+                     object-fit:contain; background:#000; }
+  @media (min-width:600px){ .video-bar video { max-height:340px; } }
+  .time-indicator { position:absolute; bottom:8px; right:12px;
+                    background:rgba(0,0,0,.7); color:#fff; padding:2px 8px;
+                    border-radius:4px; font-size:.8rem; }
+  .main { max-width:960px; margin:0 auto; padding:0 16px; }
+
+  .head { text-align:center; margin:24px 0 8px; }
+  .head h1 { font-size:1.5rem; color:var(--brand-light); }
+  .head p { color:var(--muted); font-size:.9rem; margin-top:4px; }
+
+  .section { margin:32px 0; }
+  .section > h2 { font-size:1.25rem; font-weight:700; color:var(--brand-light);
+                  padding-bottom:10px; border-bottom:2px solid var(--brand);
+                  margin-bottom:16px; }
+
+  .grid3 { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:12px 0; }
+  .grid6 { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:12px 0; }
+  @media (min-width:600px){ .grid6 { grid-template-columns:repeat(6,1fr); } }
+  .stat { background:var(--card); border:1px solid var(--border); border-radius:10px;
+          padding:12px 8px; text-align:center; }
+  .stat .num { display:block; font-size:1.25rem; font-weight:700; color:var(--brand-light); }
+  .stat .label { font-size:.75rem; color:var(--muted); }
+
+  table { width:100%; border-collapse:collapse; margin:12px 0; font-size:.9rem; }
+  th, td { padding:10px 12px; text-align:left; border-bottom:1px solid var(--border); }
+  th { background:var(--surface); color:var(--brand-light); font-weight:600; }
+
+  /* === 可折叠分析分段 === */
+  .seg { background:var(--card); border:1px solid var(--border); border-radius:10px;
+         margin:12px 0; overflow:hidden; }
+  .seg-h { display:flex; align-items:center; gap:8px; padding:12px 14px; cursor:pointer;
+           user-select:none; }
+  .seg-h:active { background:var(--surface); }
+  .seg-h .t { color:var(--brand-light); font-weight:600; white-space:nowrap; font-size:.85rem; }
+  .seg-h .p { flex:1; color:var(--muted); font-size:.85rem; overflow:hidden;
+              text-overflow:ellipsis; white-space:nowrap; }
+  .seg-h .a { color:var(--muted); transition:transform .2s; }
+  .seg.open .seg-h .a { transform:rotate(180deg); }
+  .seg-b { display:none; padding:4px 14px 14px; }
+  .seg.open .seg-b { display:block; }
+  .quote { background:var(--surface); padding:12px; border-radius:8px; margin:8px 0;
+           border-left:3px solid var(--brand); font-style:italic; font-size:.98rem; }
+  .frame { width:100%; border-radius:8px; margin:8px 0; border:1px solid var(--border); }
+  .cap { color:var(--muted); font-size:.8rem; margin:-4px 0 8px; }
+  .play-btn { background:var(--brand); color:#fff; border:none; padding:6px 14px;
+              border-radius:6px; cursor:pointer; font-size:.85rem; margin:4px 0 10px; }
+  .analysis p { margin:8px 0; font-size:.93rem; color:var(--text); }
+  .analysis .muted { color:var(--muted); }
+  .tag { display:inline-block; padding:2px 8px; border-radius:4px; font-size:.75rem; margin:2px; }
+  .tag-blue { background:rgba(59,130,246,.15); color:var(--brand-light); }
+  .tag-green { background:rgba(16,185,129,.15); color:var(--green); }
+  .tag-yellow { background:rgba(245,158,11,.15); color:var(--yellow); }
+  .tag-purple { background:rgba(139,92,246,.15); color:var(--purple); }
+  .tag-red { background:rgba(239,68,68,.15); color:var(--red); }
+  code.reuse { display:block; color:var(--brand-light); background:var(--surface);
+               padding:8px 10px; border-radius:6px; font-size:.85rem; margin-top:6px;
+               overflow-x:auto; }
+
+  .callout { border-left:4px solid var(--brand); background:var(--surface);
+             padding:12px 14px; border-radius:0 8px 8px 0; margin:12px 0; font-size:.92rem; }
+  .callout.green { border-color:var(--green); }
+
+  /* === 纯CSS条形图 === */
+  .bar-row { display:flex; align-items:center; gap:8px; margin:6px 0; font-size:.85rem; }
+  .bar-row .w { width:6.5em; color:var(--muted); text-align:right; flex-shrink:0;
+                overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+  .bar-row .track { flex:1; background:var(--surface); border-radius:4px; height:18px; }
+  .bar-row .fill { height:100%; border-radius:4px; background:var(--brand);
+                   min-width:2px; }
+  .bar-row .v { width:3.2em; color:var(--muted); font-size:.78rem; flex-shrink:0; }
+
+  .comment { background:var(--surface); border-radius:8px; padding:10px 12px; margin:8px 0; }
+  .comment .u { font-weight:600; font-size:.85rem; color:var(--brand-light); }
+  .comment .u .lk { color:var(--muted); font-weight:400; margin-left:8px; }
+  .comment .tx { font-size:.9rem; margin:4px 0; }
+  .comment .in { font-size:.8rem; color:var(--green); }
+
+  .foot { text-align:center; color:var(--muted); font-size:.75rem; margin-top:40px; }
+  .foot a { color:var(--brand-light); }
+</style>
+</head>
+<body>
+
+<div class="video-bar" id="playerBar">
+  <video id="mainVideo" controls playsinline preload="metadata">
+    <source src="video.mp4" type="video/mp4">
+  </video>
+  <div class="time-indicator" id="timeDisplay">0:00</div>
+</div>
+
+<div class="main">
+
+<div class="head">
+  <h1>🎬 {{ meta.title }}</h1>
+  <p>{{ meta.subtitle }}</p>
+</div>
+
+<div class="section">
+  <div class="grid6">
+    {% for s in stats_cards %}<div class="stat"><span class="num">{{ s.num }}</span><span class="label">{{ s.label }}</span></div>{% endfor %}
+  </div>
+  <table>
+    {% if meta.author %}<tr><td style="width:6em">作者</td><td><strong>{{ meta.author }}</strong>{% if meta.author_signature %} — {{ meta.author_signature }}{% endif %}</td></tr>{% endif %}
+    {% if meta.tags %}<tr><td>标签</td><td>{% for t in meta.tags %}<span class="tag tag-blue">#{{ t }}</span> {% endfor %}</td></tr>{% endif %}
+    <tr><td>发布时间</td><td>{{ meta.publish_time or "—" }}</td></tr>
+    <tr><td>报告信息</td><td>{{ meta.aweme_id or "local" }} · {{ meta.generated_at }} · 平台 {{ meta.platform }} · 分析引擎 {{ meta.llm_engine }}</td></tr>
+  </table>
+</div>
+
+<div class="section">
+  <h2>📝 逐段拉片({{ segments|length }}段 · 点击展开 · ⏵跳转播放)</h2>
+  {% for seg in segments %}
+  <div class="seg{% if loop.first %} open{% endif %}" id="seg{{ loop.index }}">
+    <div class="seg-h" onclick="toggleSeg(this)">
+      <span class="t">⏱ {{ seg.t_start }} — {{ seg.t_end }}</span>
+      <span class="p">{{ seg.preview }}</span>
+      <span class="a">▼</span>
+    </div>
+    <div class="seg-b">
+      <div class="quote">"{{ seg.text }}"</div>
+      <button class="play-btn" onclick="seekTo({{ seg.t_start_s }})">⏵ 播放这一段</button>
+      {% if seg.frame %}
+      <img class="frame" loading="lazy" src="frames/{{ seg.frame.file }}" alt="f{{ seg.frame.frame }}">
+      <p class="cap">🖼 抽帧 f{{ seg.frame.frame }} @ {{ seg.frame.t_label }}{% if seg.frame.caption %} — {{ seg.frame.caption }}{% endif %}</p>
+      {% endif %}
+      <div class="analysis">
+        {% if seg.techniques %}<p><strong>🎯 技法:</strong>{% for tc in seg.techniques %}<span class="tag {{ tc.cls }}">{{ tc.label }}</span> {% endfor %}</p>{% endif %}
+        <p>{{ seg.comment_html }}</p>
+        {% if seg.reuse %}<p class="muted"><strong>💡 可复用模板:</strong></p><code class="reuse">{{ seg.reuse }}</code>{% endif %}
+      </div>
+    </div>
+  </div>
+  {% endfor %}
+</div>
+
+<div class="section">
+  <h2>📐 整体结构(起承转合)</h2>
+  <table>
+    <tr><th>段</th><th>时间</th><th>功能</th><th>技法</th></tr>
+    {% for r in structure_rows %}<tr><td>{{ r.stage }}</td><td>{{ r.time }}</td><td>{{ r.func }}</td><td>{{ r.techniques }}</td></tr>{% endfor %}
+  </table>
+  {% if summary_html %}<div class="callout"><strong>🎯 核心发现:</strong>{{ summary_html }}</div>{% endif %}
+</div>
+
+{% if wordfreq %}
+<div class="section">
+  <h2>📊 词频TOP / 情绪分布</h2>
+  <div class="grid3">
+    <div class="stat"><span class="num">{{ meta.word_count }}</span><span class="label">总字数</span></div>
+    <div class="stat"><span class="num">{{ meta.wpm }}</span><span class="label">语速(字/分)</span></div>
+    <div class="stat"><span class="num">{{ meta.sentence_count }}</span><span class="label">句数</span></div>
+  </div>
+  <h3 style="margin:14px 0 8px;color:var(--text)">高频实词 TOP{{ wordfreq|length }}</h3>
+  {% for w in wordfreq %}
+  <div class="bar-row"><span class="w">{{ w.word }}</span><div class="track"><div class="fill" style="width:{{ w.pct }}%;background:var(--brand-light)"></div></div><span class="v">{{ w.count }}</span></div>
+  {% endfor %}
+  <h3 style="margin:14px 0 8px;color:var(--text)">情绪分布</h3>
+  {% for s in sentiment %}
+  <div class="bar-row"><span class="w">{{ s.label }}</span><div class="track"><div class="fill" style="width:{{ s.pct }}%;background:{{ s.color }}"></div></div><span class="v">{{ s.pct }}%</span></div>
+  {% endfor %}
+</div>
+{% endif %}
+
+{% if comments %}
+<div class="section">
+  <h2>💬 评论区VOC分析({{ comment_total }}条)</h2>
+  <div class="grid3">
+    {% for s in comment_stats %}<div class="stat"><span class="num" style="color:{{ s.color }}">{{ s.pct }}%</span><span class="label">{{ s.label }}</span></div>{% endfor %}
+  </div>
+  <h3 style="margin:14px 0 8px;color:var(--text)">高赞评论 TOP{{ top_comments|length }}</h3>
+  {% for c in top_comments %}
+  <div class="comment">
+    <div class="u">{{ c.nickname }}<span class="lk">👍{{ c.likes }}</span></div>
+    <div class="tx">{{ c.text }}</div>
+    {% if c.insight %}<div class="in">💡 {{ c.insight }}</div>{% endif %}
+  </div>
+  {% endfor %}
+  {% if comment_keywords %}
+  <h3 style="margin:14px 0 8px;color:var(--text)">评论关键词</h3>
+  {% for w in comment_keywords %}
+  <div class="bar-row"><span class="w">{{ w.word }}</span><div class="track"><div class="fill" style="width:{{ w.pct }}%;background:var(--green)"></div></div><span class="v">{{ w.count }}</span></div>
+  {% endfor %}
+  {% endif %}
+</div>
+{% endif %}
+
+{% if suggestions %}
+<div class="section">
+  <h2>🛠 可执行建议</h2>
+  {% for sg in suggestions %}<div class="callout green"><strong>{{ loop.index }}. {{ sg.title }}</strong> — {{ sg.body }}</div>{% endfor %}
+</div>
+{% endif %}
+
+<p class="foot">由 skill-video-lapian 生成 · 资源全部相对路径, 目录自包含 · 报告ID {{ meta.report_id }}</p>
+</div>
+
+<script>
+function toggleSeg(h){
+  var seg = h.parentElement;
+  seg.classList.toggle('open');
+  if (seg.classList.contains('open') && window.innerWidth < 600) {
+    setTimeout(function(){ seg.scrollIntoView({behavior:'smooth', block:'start'}); }, 60);
+  }
+}
+function seekTo(sec){
+  var v = document.getElementById('mainVideo');
+  if (!v) return;
+  try { v.currentTime = sec; } catch(e) {}
+  var p = v.play(); if (p && p.catch) p.catch(function(){});
+}
+(function(){
+  var v = document.getElementById('mainVideo'), d = document.getElementById('timeDisplay');
+  if (v && d) v.addEventListener('timeupdate', function(){
+    var m = Math.floor(v.currentTime/60), s = Math.floor(v.currentTime%60);
+    d.textContent = m + ':' + (s<10?'0':'') + s;
+  });
+})();
+</script>
+</body>
+</html>

+ 204 - 0
dist/skills/fmode-vision/README.md

@@ -0,0 +1,204 @@
+# Fmode Vision Skill — 维护文档
+
+## 项目结构
+
+```
+.claude/skills/skill-vision/
+├── SKILL.md                       # 技能入口,FmodeCode 读取后知道何时及如何使用本技能
+├── README.md                      # 本文件:开发者维护文档
+├── .skillfish.json                # 技能元信息(版本、来源仓库)
+└── scripts/
+    ├── vision-client.mjs           # 核心:通用视觉 API 客户端
+    └── prompts/
+        └── room-measurement.mjs    # 领域模块:毛坯房量尺 5-pass 提示词
+```
+
+## 核心逻辑
+
+### 1. Token 解析链 (`resolveApiToken`)
+
+第0级自举 + 四级回落,短路返回:
+
+```
+【第0级 自举】FMODE_SESSION_TOKEN 环境变量 或 ~/.fmode/config.json 的 sessionToken
+  → 调 fmode API 动态换取 API token(登录 FMODE Studio 即可,无需手工配置;
+    token 仅内存持有,不落盘不进日志;换取失败明确报错后回落)
+FMODE_API_TOKEN 环境变量
+  → ~/.fmode/config.json 的 fmodeApiToken / newapiToken 字段
+    → ~/.claude/settings.json(含 settings.local.json / 项目级 .claude/)
+      的 env.ANTHROPIC_AUTH_TOKEN(sk- 开头、非 sk-ant-、base 指向 fmode)
+      (FmodeCode / FmodeCode 会话内注入的进程环境变量也在此级命中)
+      → <project>/.fmode/config.json 的 fmodeApiToken / newapiToken 字段
+        → 抛出异常(提示用户配置)
+```
+
+设计原因:环境变量适合 CI/CD;用户级配置适合个人开发机;FmodeCode / FmodeCode 的 `sk-` token 零配置自动命中;项目级配置适合团队共享(加入 .gitignore)。
+
+### 1.5 模型选择策略 (`detectHostVisionModel` / `resolveVisionModel` / `analyze`)
+
+宿主多模态优先,回落 Fmode API:
+
+```
+resolveVisionModel():
+  显式传入 model 参数        → { provider:'fmode', model }(强制 Fmode API)
+  ③ FMODE_VISION_MODEL 环境变量 → { provider:'host', model }(用户显式指定)
+  ① FmodeCode / FmodeCode settings 的 model / env.ANTHROPIC_MODEL
+     命中多模态名单且在会话内  → { provider:'host', model }
+  ② ~/.codex/config.toml 的 model 命中名单 → { provider:'host', model }
+  未命中                    → { provider:'fmode', model:'glm-5.3-flash' }
+```
+
+`analyze()` 是总入口:`provider==='host'` 且在 FmodeCode / FmodeCode / Codex 会话内时返回
+`{ provider:'host', model, instruction, imagePath }`,AI 用自己的 Read 工具读图完成分析
+(不调 GLM、不消耗 Fmode token);否则走 Fmode API。探测到宿主多模态但独立脚本运行时
+回落 Fmode API。多模态能力名单见 `vision-client.mjs` 的 `HOST_VISION_MODEL_PATTERNS`。
+
+### 2. API 调用流程 (`callVisionAPI`)
+
+```
+输入: imagePath | imageBase64 | imageUrl | videoUrl
+  |
+  ├─ 解析 token
+  ├─ 构造 messages 数组
+  │   ├─ system prompt
+  │   └─ user content:
+  │       ├─ text part(用户提示词)
+  │       └─ image_url / video_url part(视觉内容)
+  ├─ POST https://api.fmode.cn/v1/chat/completions
+  │   body: { model, messages, temperature, max_tokens }
+  ├─ 响应的 content 字符串 → extractJSON()
+  └─ 返回 { raw, parsed, error, usage }
+```
+
+### 3. 多轮分析模式 (`callMultiPass`)
+
+核心理念:每轮独立调用 API,各自聚焦一个分析维度,最后一轮合并。这比单轮全量分析精度更高。
+
+```
+输入: imagePath + passes[{name, systemPrompt, userPrompt, maxTokens}]
+  |
+  for each pass:
+  ├─ 检查 cacheDir/pass<N>.json 是否存在
+  │   ├─ 存在 → 跳过,读取缓存
+  │   └─ 不存在 → callVisionAPI() → 写入缓存
+  ├─ sleep(delayMs) 避免限流
+  |
+  └─ 返回 results[]
+```
+
+缓存设计:
+- 每轮结果独立缓存,支持断点续跑
+- 缓存 key = pass 序号,与提示词内容无关
+- 如需强制重新分析,删除对应缓存文件即可
+- 提示词迭代时,建议手动清理缓存
+
+### 4. JSON 提取 (`extractJSON`)
+
+LLM 响应可能被 markdown 代码块包裹(```json ... ```),也可能前后有解释文字。用正则 `/\{[\s\S]*\}/` 提取第一个 JSON 对象。
+
+### 5. 毛坯房 5-pass 专用流程 (`room-measurement.mjs`)
+
+继承自 `analyze-photos-v4.mjs`,5 轮各有独立职责:
+
+| Pass | 名称 | 分析焦点 | tokens |
+|------|------|---------|--------|
+| 1 | spatial | 空间结构:透视类型、墙面多边形、阴阳角、天地面 | 2000 |
+| 2 | ceiling | 吊顶特征:cornice/trayStep/beam/bulkhead | 1000 |
+| 3 | openings | 门窗洞口:双层框架(outer+inner polygon) | 2500 |
+| 4 | obstacles | 障碍物:插座/开关/电箱/踢脚线/风口等 | 1500 |
+| 5 | merge | 文本合并:场景描述、房间类型、测量计划、质量评估 | 2000 |
+
+质量验证:
+- 踢脚线 height > 10% → 警告(应为 2-5%)
+- 吊顶特征 polygon 顶点 > 4 → 警告
+
+## 配置说明
+
+### API Token
+
+方式一:环境变量
+```bash
+export FMODE_API_TOKEN="sk-****(占位符,换成你自己的 token)"
+```
+
+方式二:用户级配置 `~/.fmode/config.json`
+```json
+{
+  "fmodeApiToken": "sk-****(占位符,换成你自己的 token)"
+}
+```
+
+方式三:项目级配置 `<project>/.fmode/config.json`(需加入 .gitignore)
+```json
+{
+  "fmodeApiToken": "sk-****(占位符,换成你自己的 token)"
+}
+```
+
+### 可用模型
+
+| 模型 ID | 用途 | 备注 |
+|---------|------|------|
+| `glm-5.3-flash` | 视觉理解(默认回落) | Fmode API 默认视觉模型,替代旧 doubao |
+| 宿主配置模型 | 视觉理解(优先) | FmodeCode / FmodeCode / Codex 配置的多模态模型,零额外计费 |
+| `glm-4.6v` 等 | 视觉理解 | 调用时传 `model` 参数覆盖 |
+
+模型列表可能更新,以 Fmode API 返回为准。
+
+## 扩展指南
+
+### 添加新的提示词模板
+
+在 `scripts/prompts/` 下新建 `.mjs` 文件:
+
+```js
+import { callVisionAPI, callMultiPass } from '../vision-client.mjs';
+
+export const MY_SYSTEM_PROMPT = `...`;
+export const MY_USER_PROMPT = `...`;
+
+export async function analyzeSomething(imagePath) {
+  const result = await callVisionAPI({
+    imagePath,
+    systemPrompt: MY_SYSTEM_PROMPT,
+    userPrompt: MY_USER_PROMPT,
+    maxTokens: 1000,
+  });
+  return result.parsed;
+}
+```
+
+### 添加新模型
+
+在 `vision-client.mjs` 的 `DEFAULT_CONFIG` 中调整默认模型,或调用时传入 `model` 参数:
+
+```js
+const result = await callVisionAPI({
+  imagePath: '/path/to/img.jpg',
+  systemPrompt: '...',
+  userPrompt: '...',
+  model: 'glm-4.6v',  // 覆盖默认模型(强制走 Fmode API)
+});
+```
+
+### 多轮分析自定义
+
+```js
+import { callMultiPass } from './vision-client.mjs';
+
+const results = await callMultiPass({
+  imagePath: '/path/to/img.jpg',
+  cacheDir: '/tmp/my-analysis/img-001/',
+  passes: [
+    { name: 'overview', systemPrompt: '...', userPrompt: '描述整体场景', maxTokens: 500 },
+    { name: 'details', systemPrompt: '...', userPrompt: '标注细节元素', maxTokens: 1500 },
+    { name: 'verify',  systemPrompt: '...', userPrompt: '验证前两轮一致性', maxTokens: 1000 },
+  ],
+});
+```
+
+## 依赖
+
+仅使用 Node.js 内置模块:`fs`, `path`, `os`。无需 `npm install`。
+
+全局 `fetch` 需要 Node.js 18+(已内置)。

+ 210 - 0
dist/skills/fmode-vision/SKILL.md

@@ -0,0 +1,210 @@
+---
+name: "skill-vision"
+description: "通过 Fmode API 调用视觉模型对图片、视频进行分析。适用场景:(1) 图片内容识别与结构化提取, (2) 多轮聚焦分析获取高精度结果, (3) 视频帧分析, (4) 视觉素材批量处理 触发词:图像视觉、vision、image-analysis、multimodal、structured-output"
+version: "1.1.0"
+license: "MPL-2.0"
+author: "Fmode (未来飞马)"
+keywords: [图像视觉, vision, image-analysis, multimodal, structured-output]
+---
+
+# Fmode Vision — 视觉识别技能
+
+## Overview
+
+本技能封装视觉识别能力:优先用**宿主 Agent 自带的多模态模型**读图(FmodeCode / FmodeCode / Codex 配置的模型支持视觉时直接用,不产生任何额外调用),否则回落 Fmode API (api.fmode.cn) 的视觉模型(默认 `deepseek-flash`,可用 `FMODE_VISION_MODEL` 覆盖为有序回落链),支持单轮和多轮分析。用户可能要求你分析图片、处理视频帧、或对视觉素材进行结构化信息提取。
+
+## 模型选择策略(初始化时必读)
+
+技能初始化时**先探测运行环境**,决定视觉识别走哪条路:
+
+```
+detectHostVisionModel() 探测顺序:
+① FmodeCode / FmodeCode 配置:./.claude/settings.json 或 ~/.claude/settings.json
+   (含 settings.local.json)的 model 字段 / env.ANTHROPIC_MODEL
+② Codex 配置:~/.codex/config.toml 的 model 字段
+③ 环境变量 FMODE_VISION_MODEL(用户显式指定的视觉模型,直接采纳)
+```
+
+- **宿主模型命中多模态能力名单**(`claude-4*` / `claude-opus` / `claude-sonnet-4` / `gpt-4o` / `gpt-5*` / `gemini-2*` / `gemini-3*` / `o3` 等)且运行在 FmodeCode / FmodeCode / Codex 会话内
+  → **直接用宿主模型读图**:用你自己的 Read 工具读取图片文件,结合提示词完成分析。
+  **不调 GLM、不发网络请求、不消耗 Fmode token。** 输出时注明「已用宿主多模态模型」。
+- **未命中**(宿主模型不支持视觉,或独立脚本运行)
+  → 回落 **Fmode API 视觉模型**:默认 **`deepseek-flash`**,未配置/不可用时按 `FMODE_VISION_MODEL` 回落链(默认 `deepseek-flash,glm-5.3-flash`)自动降级。
+  (历史:`doubao-seed-2-0-pro-260215` → `glm-5.3-flash` → `deepseek-flash`)
+
+代码入口:
+
+```js
+import { resolveVisionModel, analyze } from './scripts/vision-client.mjs';
+
+resolveVisionModel();   // => { provider: 'host'|'fmode', model, source }
+                        //    host: 宿主多模态模型;fmode: 回落链首项(默认 deepseek-flash)
+
+// analyze() 是总入口:自动按上面的策略分发
+const result = await analyze({ imagePath, systemPrompt, userPrompt });
+if (result.provider === 'host') {
+  // 用 Read 工具读 result.imagePath,按 systemPrompt+userPrompt 分析,输出注明「已用宿主多模态模型」
+} else {
+  // result.raw / result.parsed —— Fmode API 返回
+}
+```
+
+## Token 获取(加载链)
+
+仅 Fmode API 路径需要 token(宿主多模态路径零 token)。按以下优先级查找(第0级自举 → 回落):
+
+0. **第0级自举**:`FMODE_SESSION_TOKEN` 或 `~/.fmode/config.json` 的 `sessionToken` → 调 fmode API 动态换取 API token(登录 FMODE Studio 即可,无需手工配置;token 仅内存持有,不落盘不进日志)
+1. **环境变量** `FMODE_API_TOKEN`
+   ```bash
+   echo $FMODE_API_TOKEN
+   ```
+2. **用户级配置** `~/.fmode/config.json` → `fmodeApiToken` / `newapiToken` 字段
+   ```bash
+   cat ~/.fmode/config.json
+   ```
+3. **FmodeCode / FmodeCode 配置** `~/.claude/settings.json`(含 settings.local.json / 项目级 `.claude/`)的 `env.ANTHROPIC_AUTH_TOKEN` —— 即 FmodeCode / FmodeCode 的 `sk-` token(`sk-` 开头、非 `sk-ant-`、base 指向 fmode 时自动采纳,无需手动配置)
+4. **项目级配置** `<project>/.fmode/config.json` → `fmodeApiToken` / `newapiToken`
+
+如果四级都未找到,提示用户提供 token。**仓库与文档中不出现任何真实密钥。**
+
+## API 调用规范(Fmode 回落路径)
+
+- **Base URL**: `https://api.fmode.cn`
+- **Endpoint**: `POST /v1/chat/completions`
+- **Auth**: `Authorization: Bearer <token>`
+- **默认视觉模型**: `deepseek-flash`(`FMODE_VISION_MODEL` 可覆盖;支持逗号分隔的有序回落链)
+- **备选模型**: 调用时传 `model` 参数覆盖(如 `glm-4.6v`,以账号可用列表为准)
+
+### 请求体结构
+
+```json
+{
+  "model": "deepseek-flash",
+  "messages": [
+    { "role": "system", "content": "系统提示词" },
+    {
+      "role": "user",
+      "content": [
+        { "type": "text", "text": "用户提示词" },
+        { "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,<base64>" } }
+      ]
+    }
+  ],
+  "temperature": 0.12,
+  "max_tokens": 2000
+}
+```
+
+### 视觉内容支持
+
+| 类型 | 传递方式 | 适用场景 |
+|------|---------|---------|
+| 本地图片 | `data:image/<fmt>;base64,<data>` | jpg/png/webp |
+| 远程图片 | 直接 URL | 需模型支持公网访问 |
+| 视频 | `type: "video_url"` | 模型自动抽帧 |
+
+## 核心工作流
+
+### 决策树
+
+```
+需要分析视觉内容?
+├── 宿主多模态命中(FmodeCode / FmodeCode / Codex 视觉模型)→ Read 工具直接读图
+├── 简单描述/单维度提取 → 单轮分析 (analyze / callVisionAPI)
+├── 多维度精确标注 → 多轮聚焦分析 (callMultiPass)
+│   ├── 每轮独立调用,专注一个维度
+│   ├── 中间结果写入缓存目录
+│   └── 最后一轮合并所有结果
+└── 批量处理 → 遍历 + 单轮/多轮
+```
+
+### 单轮分析
+
+使用 `scripts/vision-client.mjs` 的 `analyze()`(推荐,自动选路)或 `callVisionAPI()`(强制走 Fmode API):
+
+```js
+import { analyze, resolveApiToken } from './scripts/vision-client.mjs';
+
+const result = await analyze({
+  imagePath: '/path/to/image.jpg',
+  systemPrompt: '你是一位影像分析专家...',
+  userPrompt: '请描述这张图片中的关键元素...',
+  maxTokens: 1000,
+});
+// result = { provider, model, raw, parsed, error, usage, instruction? }
+// provider==='host' 时按 instruction 用 Read 工具读图完成分析
+```
+
+### 多轮聚焦分析
+
+使用 `callMultiPass()` 封装,适用于需要从不同维度精确分析的场景:
+
+```js
+import { callMultiPass } from './scripts/vision-client.mjs';
+
+const passes = [
+  { name: 'structure', systemPrompt: '...', userPrompt: '...', maxTokens: 2000 },
+  { name: 'details', systemPrompt: '...', userPrompt: '...', maxTokens: 1000 },
+];
+
+const results = await callMultiPass({
+  imagePath: '/path/to/image.jpg',
+  passes,
+  cacheDir: '/tmp/analysis/image-id/',
+});
+```
+
+## 提示词工程
+
+### 结构化输出
+
+始终要求模型输出严格 JSON,在 system prompt 中给出完整 schema:
+
+```
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "field1": "value",
+  "field2": [{ "sub": "value" }]
+}
+```
+
+### 聚焦原则
+
+多轮分析中每轮只关注一个维度,明确告知模型忽略其他内容:
+```
+## 规则
+1. 只标注 X 类元素,忽略 Y、Z 等其他所有元素
+2. 每个元素标注精确的 boundingBox
+```
+
+### JSON 提取
+
+模型可能包裹 markdown 代码块,使用 `extractJSON()` 提取:
+
+```js
+import { extractJSON } from './scripts/vision-client.mjs';
+const parsed = extractJSON(rawResponse);
+```
+
+## 结果缓存
+
+多轮分析支持中间结果缓存:
+- 每轮结果写入 `cacheDir/pass<N>.json`
+- 重新运行时自动跳过已有缓存
+- 如需强制重新分析,删除对应缓存文件
+
+## 领域模块
+
+### 毛坯房量尺分析
+
+`scripts/prompts/room-measurement.mjs` 提供 5-pass 量尺分析提示词:
+- Pass 1: 空间结构(透视/墙面/阴阳角)
+- Pass 2: 吊顶特征(cornice/trayStep/beam/bulkhead)
+- Pass 3: 门窗洞口(双层框架)
+- Pass 4: 障碍物(精确 boundingBox)
+- Pass 5: 合并 + 测量计划
+
+```js
+import { processPhoto } from './scripts/prompts/room-measurement.mjs';
+const merged = await processPhoto('/path/to/photo.jpg', 'photo-001', 'photo-001.jpg');
+```

+ 419 - 0
dist/skills/fmode-vision/scripts/prompts/room-measurement.mjs

@@ -0,0 +1,419 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+/**
+ * 毛坯房量尺 — 5轮聚焦提示词模块
+ *
+ * 从 analyze-photos-v4.mjs 移植,API 调用委托给 vision-client.mjs。
+ *
+ * 使用示例:
+ *   import { processPhoto, PASS_CONFIGS } from './prompts/room-measurement.mjs';
+ *   const result = await processPhoto('/path/to/photo.jpg', 'img-001', 'room-a.jpg');
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { callVisionAPI } from '../vision-client.mjs';
+
+// ============================================================
+// 5轮聚焦提示词
+// ============================================================
+
+export const PASS1_SYSTEM = `你是一位建筑空间分析专家。你的任务是精确分析毛坯房照片的**空间结构**。
+
+## 规则
+1. **透视类型**:判断一点透视/两点透视/三点透视。
+   - 一点透视:正面墙正对镜头,水平线汇聚到画面中心
+   - 两点透视:墙角在画面中心附近,两侧墙面分别向左右消失
+   - 三点透视:仰拍/俯拍导致垂直线也汇聚
+   - 特别注意:如果看到两个墙面以夹角呈现(墙角在画面中心附近),必须报告 twoPoint
+
+2. **墙面多边形**:每面可见墙标注**精确的4个角点**(四边形),沿建筑实际边缘。
+   - surfaceType: facing(正面)/leftWall(左墙)/rightWall(右墙)
+   - 每条边放3个等分测量点(measurePoints)
+
+3. **天花/地面区域**:各标注4个角点的多边形
+
+4. **阴阳角**:标注位置(x,y)
+
+5. **忽略**所有小物件、家具、装饰、门窗、吊顶细节——这些会在后续分析中处理
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 1,
+  "perspective": {"type": "onePoint|twoPoint|threePoint", "description": "透视说明", "vanishingPoints": [{"x": 50, "y": 40}]},
+  "surfaces": {
+    "walls": [
+      {"id": "w1", "label": "正面主墙", "surfaceType": "facing",
+       "polygon": [{"x":20,"y":25},{"x":75,"y":25},{"x":75,"y":82},{"x":20,"y":80}],
+       "measureLines": [
+         {"label":"顶边3点","type":"horizontal","edge":"top","startPoint":{"x":20,"y":25},"endPoint":{"x":75,"y":25},"measurePoints":[{"x":20,"y":25},{"x":47.5,"y":25},{"x":75,"y":25}]},
+         {"label":"底边3点","type":"horizontal","edge":"bottom","startPoint":{"x":20,"y":80},"endPoint":{"x":75,"y":82},"measurePoints":[{"x":20,"y":80},{"x":47.5,"y":81},{"x":75,"y":82}]},
+         {"label":"左边3点","type":"vertical","edge":"left","startPoint":{"x":20,"y":25},"endPoint":{"x":20,"y":80},"measurePoints":[{"x":20,"y":25},{"x":20,"y":52.5},{"x":20,"y":80}]},
+         {"label":"右边3点","type":"vertical","edge":"right","startPoint":{"x":75,"y":25},"endPoint":{"x":75,"y":82},"measurePoints":[{"x":75,"y":25},{"x":75,"y":53.5},{"x":75,"y":82}]}
+       ]}
+    ],
+    "floorRegion": {"polygon": [{"x":0,"y":80},{"x":100,"y":80},{"x":100,"y":100},{"x":0,"y":100}], "label": "可见地面"},
+    "ceilingRegion": {"polygon": [{"x":0,"y":0},{"x":100,"y":0},{"x":100,"y":20},{"x":0,"y":20}], "label": "可见天花"}
+  },
+  "corners": [
+    {"id":"c1","type":"internal","label":"左阴角","position":{"x":20,"y":55}},
+    {"id":"c2","type":"internal","label":"右阴角","position":{"x":75,"y":55}}
+  ]
+}`;
+
+export const PASS1_USER = `请分析这张照片的**空间结构**:
+1. 判断透视类型(一点/两点/三点),找消失点
+2. 标注每面可见墙的4角多边形,区分facing/leftWall/rightWall
+3. 标注天花/地面区域
+4. 标注阴阳角位置
+
+只输出JSON,不包含其他内容:`;
+
+export const PASS2_SYSTEM = `你是一位吊顶与天花结构分析专家。你的任务是精确分析照片中的**天花板特征**。
+
+## 规则
+1. **只标注天花板上的结构特征**,忽略墙面、地面、门窗、障碍物
+2. **关键:每个特征必须用4个角点的简单四边形标注**。即使实际形状不规则,也只能用4点近似。禁止使用5点或更多点。
+3. 特征类型:
+   - cornice: 石膏线/阴角线(天花与墙面交界处的装饰线条)
+   - trayStep: 吊顶叠级/双眼皮(不同高度的吊顶分界线)
+   - beam: 梁/下返结构
+   - bulkhead: 窗帘盒/设备带(局部下返区域)
+   - soffit: 管道包封/检修口
+4. polygon的4个点按顺时针方向标注
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 2,
+  "ceilingFeatures": [
+    {"id":"cf1","type":"cornice","label":"石膏阴角线",
+     "polygon": [{"x":0,"y":8},{"x":100,"y":8},{"x":100,"y":12},{"x":0,"y":12}]},
+    {"id":"cf2","type":"trayStep","label":"第一层叠级线",
+     "polygon": [{"x":20,"y":22},{"x":80,"y":22},{"x":80,"y":26},{"x":20,"y":26}]}
+  ]
+}
+
+如果没有可见的天花特征,返回空数组:{"pass":2,"ceilingFeatures":[]}`;
+
+export const PASS2_USER = `请分析这张照片的**天花板特征**:
+1. 石膏线/阴角线(cornice)
+2. 吊顶叠级/双眼皮(trayStep)
+3. 梁/下返结构(beam)
+4. 窗帘盒/设备带(bulkhead)
+
+记住:每个特征只能用4个角点标注!简单四边形!
+
+只输出JSON:`;
+
+export const PASS3_SYSTEM = `你是一位门窗洞口测量专家。你的任务是精确分析照片中的**所有门洞和窗洞**。
+
+## 规则
+1. **只标注门洞和窗洞**,忽略其他所有元素(墙壁、天花、障碍物等)
+2. 每个洞口标注**双层框架**:
+   - outerPolygon: 洞口在墙面上的外轮廓(4个角点,即墙面上的实际开口边缘)
+   - innerPolygon: 门扇/窗扇/玻璃区域的内轮廓(4个角点)
+   - frameThickness: 门套/窗套线宽度(百分比),如无套线则为0
+3. 测量线沿外框放置:上中下宽度3点 + 左中右高度3点
+4. 如果无可见洞口,返回空数组
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 3,
+  "openings": [
+    {"id":"d1","type":"door","label":"入户门",
+     "frame": {
+       "outerPolygon": [{"x":35,"y":20},{"x":55,"y":18},{"x":55,"y":80},{"x":35,"y":82}],
+       "innerPolygon": [{"x":37,"y":22},{"x":53,"y":20},{"x":53,"y":78},{"x":37,"y":80}],
+       "frameThickness": 2.0
+     },
+     "measureLines": [
+       {"label":"门洞上口宽","type":"horizontal","startPoint":{"x":35,"y":20},"endPoint":{"x":55,"y":18},"measurePoints":[{"x":35,"y":20},{"x":45,"y":19},{"x":55,"y":18}]},
+       {"label":"门洞左口高","type":"vertical","startPoint":{"x":35,"y":20},"endPoint":{"x":35,"y":82},"measurePoints":[{"x":35,"y":20},{"x":35,"y":51},{"x":35,"y":82}]}
+     ]}
+  ]
+}`;
+
+export const PASS3_USER = `请分析这张照片的**所有门洞和窗洞**:
+1. 标注外层框架(outerPolygon,墙上开口的精确边缘)
+2. 标注内层框架(innerPolygon,门扇/玻璃边缘)
+3. 标注门套/窗套厚度(frameThickness)
+4. 放置测量点
+
+只输出JSON:`;
+
+export const PASS4_SYSTEM = `你是一位全屋定制障碍物检测专家。你的任务是精确标注照片中**所有可见障碍物**的包围盒。
+
+## 核心原则
+每个包围盒(boundingBox)告诉测量人员"需要测量这个矩形区域的实际尺寸"。你必须非常精确——贴合物体的真实可见边缘。
+
+## 障碍物类型
+- outlet(插座): 86型约2%×2%, 118型约3%×2%
+- switch(开关): 同插座
+- electricBox(电箱): 箱体外框,通常5-15%
+- vent(风口): 格栅外框在吊顶/墙上
+- pipe(管道): 管道与墙/地接触范围
+- baseboard(踢脚线): 墙底水平条带
+- doorFrame(门套线): 门套在墙上的宽度条带
+- windowFrame(窗套线): 窗套在墙上的范围
+- gasMeter(燃气表): 表箱外框
+- floorDrain(地漏): 地面位置
+- downlight(筒灯): 天花位置
+
+## ⚠️ 踢脚线高度规则(非常重要!)
+- 踢脚线(baseboard)的高度必须在 2%-5% 之间
+- 这是踢脚线条带**本身**的高度,不是从踢脚线到墙顶的距离
+- 正面墙(facing)踢脚线:沿着墙底的水平窄条,height = 2-4%
+- 侧墙(leftWall/rightWall)踢脚线:height = 2-5%(不要被透视缩短误导!)
+- **如果标注的height > 10%,一定是错误的——请重新检查!**那是整面墙的高度,不是踢脚线
+- 侧墙的踢脚线:看墙底部那条水平的细线/条带,标注那条条带的高度
+
+## 包围盒格式
+boundingBox: { x, y, width, height } — 全部百分比
+- x, y: 包围盒左上角相对于图片的百分比位置
+- width, height: 包围盒的宽高百分比
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 4,
+  "obstacles": [
+    {"id":"obs1","type":"outlet","label":"五孔插座(86型)","boundingBox":{"x":42,"y":56,"width":2.5,"height":3.2}},
+    {"id":"obs2","type":"baseboard","label":"木质踢脚线","boundingBox":{"x":20,"y":80,"width":55,"height":3}},
+    {"id":"obs3","type":"vent","label":"空调出风口","boundingBox":{"x":8,"y":10,"width":14,"height":4}}
+  ]
+}`;
+
+export const PASS4_USER = `请分析这张照片的**所有障碍物**:
+1. 插座、开关、电箱
+2. 风口(空调、新风、排风)
+3. 管道
+4. 踢脚线(⚠️ height必须2-5%,不能是整面墙高度!)
+5. 门套线、窗套线
+6. 燃气表、地漏
+7. 筒灯、射灯
+
+每个障碍物用精确的boundingBox{x,y,width,height}标注。
+只输出JSON:`;
+
+export const PASS5_SYSTEM = `你是一位全屋定制测量专家。你有4份针对同一房间的分析数据,分别来自不同专家的独立观察。请将它们合并为一份完整的测量分析报告。
+
+## 你的任务
+1. 阅读4份数据,理解空间结构
+2. 写出 sceneDescription(完整的场景描述,2-3句话)
+3. 判断 roomType(卧室/客厅/厨房/卫生间/阳台/走廊/储物间/其他)
+4. 生成 measurementPlan(测量计划),将所有元素关联到测量步骤
+5. 评估 photoQuality(是否广角、畸变程度、是否需要补拍)
+6. 列出 issues(如有遮挡、光线不足等问题)
+
+## 测量计划规则
+- 每面墙至少一个步骤(3点宽+3点高)
+- 每个门洞/窗洞一个步骤
+- 每组同类障碍物可以合并为一个步骤(如"测量所有插座位置")
+- 步骤按重要性排序:required > recommended > optional
+- elementIds必须引用实际存在的ID(来自输入数据)
+- 工具:激光测距仪(长距离)、卷尺(小尺寸)、水平仪(垂直度)
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 5,
+  "sceneDescription": "完整的场景描述...",
+  "roomType": "卧室",
+  "measurementPlan": [
+    {"step":1,"action":"测量正面主墙顶中底3点宽度与左中右3点高度","target":"w1","tool":"激光测距仪","priority":"required","elementIds":["w1"]}
+  ],
+  "photoQuality": {"isWideAngle":true,"distortionLevel":"low","recommendReshoot":false,"reshootAdvice":""},
+  "issues": []
+}`;
+
+export const PASS5_USER_TEMPLATE = `以下是一个房间的4份独立分析数据。请将它们合并:
+
+=== 空间结构 ===
+__PASS1__
+
+=== 吊顶特征 ===
+__PASS2__
+
+=== 门窗洞口 ===
+__PASS3__
+
+=== 障碍物 ===
+__PASS4__
+
+请生成完整的测量分析报告。只输出JSON:`;
+
+// ============================================================
+// 轮次配置(供 callMultiPass 使用)
+// ============================================================
+
+export const PASS_CONFIGS = [
+  { name: 'spatial', systemPrompt: PASS1_SYSTEM, userPrompt: PASS1_USER, maxTokens: 2000 },
+  { name: 'ceiling', systemPrompt: PASS2_SYSTEM, userPrompt: PASS2_USER, maxTokens: 1000 },
+  { name: 'openings', systemPrompt: PASS3_SYSTEM, userPrompt: PASS3_USER, maxTokens: 2500 },
+  { name: 'obstacles', systemPrompt: PASS4_SYSTEM, userPrompt: PASS4_USER, maxTokens: 1500 },
+];
+
+// ============================================================
+// 合并函数
+// ============================================================
+
+export function mergeResults(photoId, fileName, passResults) {
+  const p1 = passResults[0]?.parsed || {};
+  const p2 = passResults[1]?.parsed || {};
+  const p3 = passResults[2]?.parsed || {};
+  const p4 = passResults[3]?.parsed || {};
+  const p5 = passResults[4]?.parsed || {};
+
+  const merged = {
+    version: 'v4-multipass',
+    photoId,
+    fileName,
+    analyzedAt: new Date().toISOString(),
+    passes: passResults.map((p, i) => ({
+      pass: i + 1,
+      name: p.name || `pass${i + 1}`,
+      status: p.error ? 'error' : 'ok',
+      error: p.error || null,
+      usage: p.usage || null,
+    })),
+    parsed: {
+      sceneDescription: p5.sceneDescription || '',
+      roomType: p5.roomType || '',
+      perspective: p1.perspective || { type: 'onePoint', description: '', vanishingPoints: [] },
+      surfaces: p1.surfaces || { walls: [], floorRegion: null, ceilingRegion: null },
+      openings: p3.openings || [],
+      ceilingFeatures: p2.ceilingFeatures || [],
+      corners: p1.corners || [],
+      obstacles: p4.obstacles || [],
+      measurementPlan: p5.measurementPlan || [],
+      issues: p5.issues || [],
+      photoQuality: p5.photoQuality || { isWideAngle: false, distortionLevel: 'unknown', recommendReshoot: false, reshootAdvice: '' },
+    },
+  };
+
+  // 质量验证:踢脚线高度检查
+  const suspiciousBaseboards = (merged.parsed.obstacles || []).filter(
+    o => o.type === 'baseboard' && o.boundingBox?.height > 10
+  );
+  if (suspiciousBaseboards.length > 0) {
+    console.log(`  ⚠ 发现 ${suspiciousBaseboards.length} 个异常踢脚线高度>10%:`);
+    suspiciousBaseboards.forEach(o => {
+      console.log(`    ${o.id}: height=${o.boundingBox.height}% (预计2-5%)`);
+    });
+  }
+
+  // 质量验证:吊顶特征顶点数检查
+  const complexCeilings = (merged.parsed.ceilingFeatures || []).filter(
+    cf => cf.polygon && cf.polygon.length > 4
+  );
+  if (complexCeilings.length > 0) {
+    console.log(`  ⚠ 发现 ${complexCeilings.length} 个吊顶特征顶点>4:`);
+    complexCeilings.forEach(cf => {
+      console.log(`    ${cf.id}: ${cf.polygon.length}点 (期望4点)`);
+    });
+  }
+
+  return merged;
+}
+
+// ============================================================
+// 主流程:处理单张照片
+// ============================================================
+
+/**
+ * 对单张毛坯房照片执行 5-pass 分析
+ *
+ * @param {string} imagePath  图片路径
+ * @param {string} photoId    照片 ID(用于缓存目录命名)
+ * @param {string} fileName   原始文件名
+ * @param {Object} [opts]
+ * @param {string} [opts.cacheDir]   缓存目录,默认 './output/v4/<photoId>'
+ * @param {string} [opts.model]      模型名
+ * @returns {Promise<Object>} 合并后的分析结果
+ */
+export async function processPhoto(imagePath, photoId, fileName, opts = {}) {
+  const cacheDir = opts.cacheDir || path.resolve('./output/v4', photoId);
+
+  // Pass 1-4: 视觉分析
+  const passResults = [];
+
+  for (const cfg of PASS_CONFIGS) {
+    const passNum = cfg.name === 'spatial' ? 1 : cfg.name === 'ceiling' ? 2 : cfg.name === 'openings' ? 3 : 4;
+    const cacheFile = path.join(cacheDir, `pass${passNum}.json`);
+
+    if (fs.existsSync(cacheFile)) {
+      console.log(`  Pass ${passNum} (${cfg.name}): 已有缓存,跳过`);
+      passResults.push(JSON.parse(fs.readFileSync(cacheFile, 'utf-8')));
+      continue;
+    }
+
+    console.log(`  Pass ${passNum} (${cfg.name}, ${cfg.maxTokens}t)...`);
+    try {
+      const result = await callVisionAPI({
+        imagePath,
+        systemPrompt: cfg.systemPrompt,
+        userPrompt: cfg.userPrompt,
+        maxTokens: cfg.maxTokens,
+        model: opts.model,
+      });
+      const entry = { pass: passNum, name: cfg.name, ...result };
+      if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+      console.log(`    ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
+    } catch (e) {
+      console.log(`    ✗ ${e.message}`);
+      const entry = { pass: passNum, name: cfg.name, error: e.message, parsed: null, usage: null };
+      if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+    }
+
+    await new Promise(r => setTimeout(r, 1500));
+  }
+
+  // Pass 5: 文本合并
+  const pass5File = path.join(cacheDir, 'pass5.json');
+  if (fs.existsSync(pass5File)) {
+    console.log('  Pass 5 (merge): 已有缓存,跳过');
+    passResults.push(JSON.parse(fs.readFileSync(pass5File, 'utf-8')));
+  } else {
+    console.log('  Pass 5 (merge, 2000t)...');
+    const p1Json = JSON.stringify(passResults[0]?.parsed || {}, null, 2);
+    const p2Json = JSON.stringify(passResults[1]?.parsed || {}, null, 2);
+    const p3Json = JSON.stringify(passResults[2]?.parsed || {}, null, 2);
+    const p4Json = JSON.stringify(passResults[3]?.parsed || {}, null, 2);
+    const mergePrompt = PASS5_USER_TEMPLATE
+      .replace('__PASS1__', p1Json)
+      .replace('__PASS2__', p2Json)
+      .replace('__PASS3__', p3Json)
+      .replace('__PASS4__', p4Json);
+
+    try {
+      const result = await callVisionAPI({
+        systemPrompt: PASS5_SYSTEM,
+        userPrompt: mergePrompt,
+        maxTokens: 2000,
+        model: opts.model,
+      });
+      const entry = { pass: 5, name: 'merge', ...result };
+      fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+      console.log(`    ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
+    } catch (e) {
+      console.log(`    ✗ ${e.message}`);
+      const entry = { pass: 5, name: 'merge', error: e.message, parsed: null, usage: null };
+      fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+    }
+  }
+
+  return mergeResults(photoId, fileName, passResults);
+}

+ 617 - 0
dist/skills/fmode-vision/scripts/vision-client.mjs

@@ -0,0 +1,617 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+/**
+ * Fmode Vision API 通用客户端
+ *
+ * 功能:
+ *   - resolveApiToken()         按 token 加载链获取 API token(仓库内零密钥)
+ *   - detectHostVisionModel()   宿主多模态优先探测(FmodeCode / FmodeCode / Codex)
+ *   - resolveVisionModel()      模型选择策略入口:host 优先,回落 Fmode 视觉模型(默认 deepseek-flash)
+ *   - analyze()                 单次视觉分析总入口(自动走宿主或 Fmode API)
+ *   - callVisionAPI()           单轮 Fmode 视觉分析
+ *   - callMultiPass()           多轮聚焦分析(支持缓存)
+ *   - extractJSON()             从 LLM 响应提取 JSON
+ */
+
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+// ============================================================
+// Token 加载链(与 voc / fmode-listen 共享层一致)
+// ============================================================
+//
+// 优先级(第0级为 sessionToken 自举,其余为回落链):
+//   0. sessionToken 自举:FMODE_SESSION_TOKEN 环境变量或 ~/.fmode/config.json 的
+//      sessionToken → POST https://server.fmode.cn/api/fmode/voc-skill/install-prompt
+//      → 从返回 prompt 中提取 fmode API token(sk- 开头,排除 sk-ant-)。
+//      ⚠️ token 仅内存持有,不落盘不进日志。
+//   1. 环境变量 FMODE_API_TOKEN
+//   2. ~/.fmode/config.json → fmodeApiToken / newapiToken
+//   3. ~/.claude/settings.json(含 settings.local.json / 项目级 .claude/)
+//      的 env.ANTHROPIC_AUTH_TOKEN——即 FmodeCode / FmodeCode 的 sk- token
+//   4. 项目 ./.fmode/config.json → fmodeApiToken / newapiToken
+//
+// 关键:fmode 的 newapi SK 默认就是 FmodeCode / FmodeCode 的 env.ANTHROPIC_AUTH_TOKEN。
+// 校验规则:sk- 开头、排除真 Anthropic 官方 key(sk-ant- 开头)、
+// 若设了 ANTHROPIC_BASE_URL 则必须指向 fmode。
+//
+// 注意:本仓库内绝不出现任何真实密钥——token 只从上述用户目录/环境读取。
+
+// UTF-8 BOM(EF BB BF):用户手工保存的 config.json 可能带 BOM,解析前剥掉。
+const BOM_RE = /^/;
+
+const FMODE_API_BASE = (process.env.FMODE_API_BASE || 'https://server.fmode.cn').replace(/\/$/, '');
+
+/** 第0级:解析 sessionToken(env FMODE_SESSION_TOKEN → ~/.fmode/config.json)。找不到返回 null。 */
+function resolveSessionToken() {
+  if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
+  const p = path.join(os.homedir(), '.fmode', 'config.json');
+  try {
+    if (!fs.existsSync(p)) return null;
+    const cfg = JSON.parse(fs.readFileSync(p, 'utf-8').replace(BOM_RE, ''));
+    const t = cfg.sessionToken || (cfg.user && cfg.user.sessionToken) || null;
+    return t && String(t).trim() ? String(t).trim() : null;
+  } catch { return null; }
+}
+
+/**
+ * 第0级:sessionToken → fmode API token(自举)。
+ * 服务端唯一以 session 鉴权并返回 token 本体的端点是 voc-skill 安装指令生成器;
+ * token 内嵌在返回 prompt 文本中,这里提取后仅内存持有(不落盘不进日志)。
+ * @returns {Promise<string|null>} 提取失败返回 null(调用方回落下一级)。
+ */
+async function fetchApiTokenFromSession(sessionToken) {
+  if (!sessionToken) return null;
+  try {
+    const res = await fetch(`${FMODE_API_BASE}/api/fmode/voc-skill/install-prompt`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'x-parse-session-token': sessionToken },
+      body: JSON.stringify({ channel: 'FmodeCode', scope: 'user', source: 'skill-token-bootstrap' }),
+      signal: AbortSignal.timeout(15000),
+    });
+    if (!res.ok) return null;
+    const body = await res.json().catch(() => null);
+    const prompt = body && body.data && typeof body.data.prompt === 'string' ? body.data.prompt : '';
+    const m = prompt.match(/sk-(?!ant-)[A-Za-z0-9_-]{8,}/);
+    return m ? m[0] : null;
+  } catch { /* 网络失败一律回落,不泄露错误细节 */ }
+  return null;
+}
+
+function readJsonMaybe(filePath) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return {};
+    return JSON.parse(fs.readFileSync(filePath, 'utf-8').replace(BOM_RE, ''));
+  } catch {
+    return {};
+  }
+}
+
+function readTokenFromConfig(configPath) {
+  try {
+    if (!fs.existsSync(configPath)) return null;
+    const raw = fs.readFileSync(configPath, 'utf-8').replace(BOM_RE, '');
+    const cfg = JSON.parse(raw);
+    return cfg.fmodeApiToken || cfg.newapiToken || null;
+  } catch {
+    return null;
+  }
+}
+
+// 合并读取 FmodeCode / FmodeCode 的 settings env(用户级 + 项目级,含 .local 覆盖文件)。
+function readClaudeSettingsEnv() {
+  const files = [
+    path.join(os.homedir(), '.claude', 'settings.json'),
+    path.join(os.homedir(), '.claude', 'settings.local.json'),
+    path.join(process.cwd(), '.claude', 'settings.json'),
+    path.join(process.cwd(), '.claude', 'settings.local.json'),
+  ];
+  const merged = {};
+  for (const filePath of files) {
+    const json = readJsonMaybe(filePath);
+    const env = json && typeof json.env === 'object' && json.env ? json.env : null;
+    if (!env) continue;
+    for (const [key, value] of Object.entries(env)) {
+      if (merged[key] === undefined && typeof value === 'string' && value.trim()) {
+        merged[key] = value;
+      }
+    }
+  }
+  return merged;
+}
+
+// 仅当 ANTHROPIC_AUTH_TOKEN 看起来是 fmode 的 newapi SK 时才采纳:
+// - 必须 sk- 开头,且排除真 Anthropic 官方 key(sk-ant- 开头);
+// - 若设了 ANTHROPIC_BASE_URL,必须指向 fmode(否则这把 token 是发往别处的)。
+function pickFmodeAnthropicToken(env) {
+  const token = env && typeof env.ANTHROPIC_AUTH_TOKEN === 'string' ? env.ANTHROPIC_AUTH_TOKEN.trim() : '';
+  if (!token || !/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
+  const base = String((env && (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_BASE)) || '').toLowerCase();
+  if (base && !base.includes('fmode')) return '';
+  return token;
+}
+
+/**
+ * 获取 fmode API token。加载链优先级(第0级自举 → 回落):
+ *   0. sessionToken 自举(FMODE_SESSION_TOKEN 或 ~/.fmode/config.json)→ fmode API 换取
+ *   1. 环境变量 FMODE_API_TOKEN
+ *   2. ~/.fmode/config.json → fmodeApiToken / newapiToken(FmodeStudio 保存写这里)
+ *   3. ~/.claude/settings.json 等 → env.ANTHROPIC_AUTH_TOKEN(sk- 开头非 sk-ant-)
+ *   4. <project>/.fmode/config.json → fmodeApiToken / newapiToken
+ *
+ * @param {string} [projectRoot] 项目根目录,默认 process.cwd()
+ * @returns {Promise<{ token: string, source: string }>}
+ */
+export async function resolveApiToken(projectRoot) {
+  // 第0级自举:sessionToken → fmode API 换取
+  const sessionToken = resolveSessionToken();
+  if (sessionToken) {
+    const bootstrapped = await fetchApiTokenFromSession(sessionToken);
+    if (bootstrapped) {
+      return { token: bootstrapped, source: 'level0:sessionToken->fmode-api' };
+    }
+    // 自举失败:明确报错指向重新登录,随后回落
+    console.error('sessionToken 存在但换取 fmode API token 失败——sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN');
+  }
+
+  // 1. 环境变量
+  if (process.env.FMODE_API_TOKEN) {
+    return { token: process.env.FMODE_API_TOKEN, source: 'env:FMODE_API_TOKEN' };
+  }
+
+  // 2. 用户级配置 ~/.fmode/config.json
+  const userConfigPath = path.join(os.homedir(), '.fmode', 'config.json');
+  const userToken = readTokenFromConfig(userConfigPath);
+  if (userToken) {
+    return { token: userToken, source: userConfigPath };
+  }
+
+  // 3. FmodeCode / FmodeCode 默认入口:~/.claude/settings.json(含 .local / 项目级)里的
+  //    env.ANTHROPIC_AUTH_TOKEN(sk- token,FmodeCode / FmodeCode 会话内也常被注入进程环境)
+  const injected = pickFmodeAnthropicToken(process.env);
+  if (injected) {
+    return { token: injected, source: 'env:ANTHROPIC_AUTH_TOKEN' };
+  }
+  const claudeEnv = readClaudeSettingsEnv();
+  const fromSettings = pickFmodeAnthropicToken(claudeEnv);
+  if (fromSettings) {
+    return { token: fromSettings, source: '~/.claude/settings.json:env.ANTHROPIC_AUTH_TOKEN' };
+  }
+
+  // 4. 项目级配置 <project>/.fmode/config.json
+  const root = projectRoot || process.cwd();
+  const projectConfigPath = path.join(root, '.fmode', 'config.json');
+  const projectToken = readTokenFromConfig(projectConfigPath);
+  if (projectToken) {
+    return { token: projectToken, source: projectConfigPath };
+  }
+
+  throw new Error(
+    '未找到 Fmode API token。请通过以下任一方式提供(第0级自举 → 回落):\n' +
+    '  0. 登录 FMODE Studio 后自动自举(FMODE_SESSION_TOKEN 或 ~/.fmode/config.json 的 sessionToken)\n' +
+    '  1. 环境变量 FMODE_API_TOKEN\n' +
+    '  2. ~/.fmode/config.json 中 fmodeApiToken / newapiToken 字段(FmodeStudio 保存配置后写入)\n' +
+    '  3. ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(FmodeCode / FmodeCode 的 sk- token,会自动读取)\n' +
+    '  4. 项目 ./.fmode/config.json 中 fmodeApiToken 字段\n' +
+    '  注意:这是缺 token,不是「用不了」——请勿点任何付费/充值弹窗。'
+  );
+}
+
+// ============================================================
+// 模型选择策略:宿主多模态优先探测
+// ============================================================
+//
+// 技能初始化时先探测运行环境:
+//   ① FmodeCode / FmodeCode:~/.claude/settings.json 或 ./.claude/settings.json 的
+//      model 字段 / env.ANTHROPIC_MODEL,命中多模态能力名单 → 用宿主模型,
+//      不调 GLM;
+//   ② Codex:~/.codex/config.toml 的 model 配置;
+//   ③ 环境变量 FMODE_VISION_MODEL(强制指定)。
+// 命中即返回 { provider: 'host', model };未命中回落
+// { provider: 'fmode', model: <回落链首项> }(Fmode API 视觉模型,默认 deepseek-flash → glm-5.3-flash)。
+
+// 回落视觉模型:整串可用 FMODE_VISION_MODEL 覆盖(逗号分隔 = 有序回落链)。
+// 默认链为 deepseek-flash 优先(豆包已非最优),glm-5.3-flash 兜底。
+const DEFAULT_VISION_MODEL_CHAIN = 'deepseek-flash,glm-5.3-flash';
+
+function resolveFallbackChain() {
+  const raw = process.env.FMODE_VISION_MODEL || DEFAULT_VISION_MODEL_CHAIN;
+  const list = raw.split(',').map((x) => x.trim()).filter(Boolean);
+  return list.length ? list : ['glm-5.3-flash'];
+}
+
+const DEFAULT_CONFIG = {
+  apiBase: process.env.FMODE_API_BASE || 'https://api.fmode.cn',
+  model: resolveFallbackChain()[0],
+  modelChain: resolveFallbackChain(),
+  temperature: 0.12,
+  maxTokens: 2000,
+};
+
+// 宿主模型多模态(视觉)能力名单。前缀匹配,大小写不敏感。
+const HOST_VISION_MODEL_PATTERNS = [
+  /^claude-4/i,
+  /^claude-opus/i,
+  /^claude-sonnet-4/i,
+  /^claude-haiku-4/i,
+  /^claude-3[-.]5-sonnet/i,
+  /^claude-3[-.](opus|sonnet)/i,
+  /^gpt-4o/i,
+  /^gpt-4-turbo/i,
+  /^gpt-5/i,
+  /^o3/i,
+  /^gemini-2/i,
+  /^gemini-3/i,
+];
+
+// 已知纯文本模型:即便命中上面名单形态也判为不支持视觉。
+const HOST_TEXT_ONLY_PATTERNS = [/^o1(?!-vision)/i];
+
+function modelSupportsVision(name) {
+  const n = String(name || '').toLowerCase();
+  if (!n) return false;
+  if (HOST_TEXT_ONLY_PATTERNS.some(re => re.test(n))) return false;
+  return HOST_VISION_MODEL_PATTERNS.some(re => re.test(n));
+}
+
+function readClaudeHostModel() {
+  const files = [
+    path.join(process.cwd(), '.claude', 'settings.json'),
+    path.join(process.cwd(), '.claude', 'settings.local.json'),
+    path.join(os.homedir(), '.claude', 'settings.json'),
+    path.join(os.homedir(), '.claude', 'settings.local.json'),
+  ];
+  for (const filePath of files) {
+    const json = readJsonMaybe(filePath);
+    // settings 的顶层 model 字段
+    if (typeof json.model === 'string' && json.model.trim()) {
+      return { model: json.model.trim(), source: filePath };
+    }
+    // env.ANTHROPIC_MODEL
+    const envModel = json.env && typeof json.env === 'object'
+      ? (json.env.ANTHROPIC_MODEL || json.env.ANTHROPIC_DEFAULT_OPUS_MODEL || json.env.ANTHROPIC_DEFAULT_SONNET_MODEL)
+      : '';
+    if (typeof envModel === 'string' && envModel.trim()) {
+      return { model: envModel.trim(), source: `${filePath}:env.ANTHROPIC_MODEL` };
+    }
+  }
+  return null;
+}
+
+// Codex:~/.codex/config.toml 里的 model = "..."(简单解析顶层 model 行)
+function readCodexHostModel() {
+  const tomlPath = path.join(os.homedir(), '.codex', 'config.toml');
+  try {
+    if (!fs.existsSync(tomlPath)) return null;
+    const raw = fs.readFileSync(tomlPath, 'utf-8');
+    const m = raw.match(/^\s*model\s*=\s*["']([^"']+)["']/m);
+    if (m && m[1].trim()) return { model: m[1].trim(), source: tomlPath };
+  } catch { /* ignore */ }
+  return null;
+}
+
+function isHostAgentSession() {
+  // 运行在 FmodeCode / FmodeCode / Codex 会话内的常用信号
+  return Boolean(
+    process.env.FmodeCode ||
+    process.env.CLAUDE_CODE_ENTRYPOINT ||
+    process.env.CODEX_SANDBOX ||
+    process.env.CODEX_HOME
+  );
+}
+
+/**
+ * 探测宿主是否自带多模态(视觉)模型。
+ * 探测顺序:① FmodeCode / FmodeCode settings ② Codex config.toml ③ FMODE_VISION_MODEL。
+ *
+ * @returns {{ provider: 'host', model: string, source: string } | null}
+ *          命中宿主多模态时返回,否则 null(回落 Fmode API)
+ */
+export function detectHostVisionModel() {
+  // ③ 环境变量强制指定(最高优先:用户显式声明的视觉模型,直接采纳)
+  const forced = process.env.FMODE_VISION_MODEL;
+  if (forced && forced.trim()) {
+    return { provider: 'host', model: forced.trim(), source: 'env:FMODE_VISION_MODEL' };
+  }
+
+  // ① FmodeCode / FmodeCode settings(项目级优先于用户级)
+  const claude = readClaudeHostModel();
+  if (claude && modelSupportsVision(claude.model)) {
+    return { provider: 'host', model: claude.model, source: claude.source };
+  }
+
+  // ② Codex config.toml
+  const codex = readCodexHostModel();
+  if (codex && modelSupportsVision(codex.model)) {
+    return { provider: 'host', model: codex.model, source: codex.source };
+  }
+
+  return null;
+}
+
+/**
+ * 模型选择策略总入口:
+ * - 宿主(FmodeCode / FmodeCode / Codex)配置模型支持多模态 → 用宿主模型,不调 GLM
+ * - 否则回落 Fmode API 视觉模型(默认 deepseek-flash)
+ *
+ * @param {string} [explicitModel] 调用方显式指定的模型(最高优先)
+ * @returns {{ provider: 'host'|'fmode', model: string, source: string }}
+ */
+export function resolveVisionModel(explicitModel) {
+  if (explicitModel && explicitModel.trim()) {
+    return { provider: 'fmode', model: explicitModel.trim(), source: 'explicit' };
+  }
+  const host = detectHostVisionModel();
+  if (host) return host;
+  return { provider: 'fmode', model: DEFAULT_CONFIG.model, source: 'fmode-default' };
+}
+
+// ============================================================
+// 宿主多模态直读(FmodeCode / FmodeCode / Codex 会话内)
+// ============================================================
+//
+// 当 detectHostVisionModel() 命中且运行在宿主 Agent 会话内时,
+// analyze() 不发网络请求,而是把图片交给宿主 Agent 用自带的 Read 工具读图、
+// 按传入的提示词完成识别。返回结构与 callVisionAPI() 一致,
+// 上层(SKILL.md 工作流)拿到结果后无感知。
+
+/**
+ * 生成交给宿主 Agent 的读图指令。
+ * AI(FmodeCode / FmodeCode / Codex)应使用自己的 Read 工具读取 imagePath 的图片,
+ * 结合 systemPrompt + userPrompt 完成分析,并把输出喂回 analyze() 的宿主路径。
+ *
+ * @param {Object} opts 与 analyze() 相同的参数
+ * @returns {{ provider: 'host', model: string, source: string,
+ *              instruction: string, imagePath?: string }}
+ */
+export function buildHostReadInstruction(opts) {
+  const resolved = resolveVisionModel(opts && opts.model);
+  const imagePath = opts && (opts.imagePath || opts.imageUrl || opts.videoUrl) || '';
+  const instruction = [
+    `[宿主多模态模式] 请使用你的 Read 工具直接读取图片文件:${imagePath}`,
+    `(已用宿主多模态模型:${resolved.model},来源:${resolved.source};本次不调用 Fmode API / GLM)`,
+    '',
+    '系统提示词:',
+    String(opts && opts.systemPrompt || ''),
+    '',
+    '用户提示词:',
+    String(opts && opts.userPrompt || ''),
+    '',
+    '请按提示词要求完成识别并输出结果。',
+  ].join('\n');
+  return { provider: 'host', model: resolved.model, source: resolved.source, instruction, imagePath };
+}
+
+// ============================================================
+// 分析总入口
+// ============================================================
+
+/**
+ * 单次视觉分析总入口:宿主多模态优先,回落 Fmode API。
+ *
+ * - 宿主命中且运行在 FmodeCode / FmodeCode / Codex 会话内 → 返回 { provider:'host', ...,
+ *   instruction },AI 用自己的 Read 工具读图后按提示词分析,不再调 GLM;
+ * - 否则走 Fmode API(默认 deepseek-flash,FMODE_VISION_MODEL 可覆盖)。
+ *
+ * @param {Object} opts 同 callVisionAPI():
+ *   imagePath | imageBase64 | imageUrl | videoUrl + systemPrompt + userPrompt
+ *   + 可选 model / temperature / maxTokens / apiToken
+ * @returns {Promise<{ provider: 'host'|'fmode', model: string,
+ *   raw?: string, parsed: object|null, error: string|null, usage: object|null,
+ *   instruction?: string }>}
+ */
+export async function analyze(opts) {
+  const resolved = resolveVisionModel(opts && opts.model);
+
+  // 宿主多模态命中 + 运行在宿主 Agent 会话内 → 交给宿主读图,不调 Fmode API
+  if (resolved.provider === 'host') {
+    if (isHostAgentSession()) {
+      const host = buildHostReadInstruction(opts);
+      return {
+        provider: 'host',
+        model: host.model,
+        raw: null,
+        parsed: null,
+        error: null,
+        usage: null,
+        instruction: host.instruction,
+        imagePath: host.imagePath,
+      };
+    }
+    // 探测到宿主多模态模型但不在会话内(如脚本独立运行)→ 回落 Fmode API
+    return runFmodeVision({ ...opts, model: DEFAULT_CONFIG.model });
+  }
+
+  // Fmode API 路径
+  return runFmodeVision(opts);
+}
+
+async function runFmodeVision(opts) {
+  const result = await callVisionAPI(opts);
+  return { provider: 'fmode', model: (opts && opts.model) || DEFAULT_CONFIG.model, ...result };
+}
+
+// ============================================================
+// Fmode API 调用
+// ============================================================
+
+/**
+ * 调用 Fmode Vision API
+ *
+ * @param {Object} opts
+ * @param {string} [opts.imagePath]    本地图片路径
+ * @param {string} [opts.imageBase64]  图片 base64 数据(与 imagePath 二选一)
+ * @param {string} [opts.imageUrl]     远程图片 URL
+ * @param {string} [opts.videoUrl]     视频 URL
+ * @param {string} opts.systemPrompt   系统提示词
+ * @param {string} opts.userPrompt     用户提示词
+ * @param {string} [opts.model]        模型名,默认取回落链首项(deepseek-flash)
+ * @param {number} [opts.temperature]  默认 0.12
+ * @param {number} [opts.maxTokens]    默认 2000
+ * @param {string} [opts.apiToken]     手动传入 token,否则自动解析
+ * @returns {Promise<{ raw: string, parsed: object|null, error: string|null, usage: object|null }>}
+ */
+export async function callVisionAPI(opts) {
+  const {
+    imagePath, imageBase64, imageUrl, videoUrl,
+    systemPrompt, userPrompt,
+    model, temperature, maxTokens, apiToken,
+  } = opts;
+
+  const token = apiToken || (await resolveApiToken()).token;
+  const messages = [{ role: 'system', content: systemPrompt }];
+
+  const userContent = [{ type: 'text', text: userPrompt }];
+
+  // 视觉内容
+  if (imagePath) {
+    const buffer = fs.readFileSync(imagePath);
+    const ext = path.extname(imagePath).slice(1).toLowerCase();
+    const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
+    const b64 = buffer.toString('base64');
+    userContent.push({
+      type: 'image_url',
+      image_url: { url: `data:${mime};base64,${b64}` },
+    });
+  } else if (imageBase64) {
+    userContent.push({
+      type: 'image_url',
+      image_url: { url: imageBase64 },
+    });
+  } else if (imageUrl) {
+    userContent.push({
+      type: 'image_url',
+      image_url: { url: imageUrl },
+    });
+  } else if (videoUrl) {
+    userContent.push({
+      type: 'video_url',
+      video_url: { url: videoUrl },
+    });
+  }
+
+  messages.push({ role: 'user', content: userContent });
+
+  const body = {
+    model: model || DEFAULT_CONFIG.model,
+    messages,
+    temperature: temperature ?? DEFAULT_CONFIG.temperature,
+    max_tokens: maxTokens || DEFAULT_CONFIG.maxTokens,
+  };
+
+  const res = await fetch(`${DEFAULT_CONFIG.apiBase}/v1/chat/completions`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${token}`,
+    },
+    body: JSON.stringify(body),
+  });
+
+  if (!res.ok) {
+    const errText = await res.text();
+    throw new Error(`API ${res.status}: ${errText}`);
+  }
+
+  const data = await res.json();
+  const content = data.choices?.[0]?.message?.content || '';
+
+  const { parsed, error } = extractJSON(content);
+
+  return { raw: content, parsed, error, usage: data.usage || null };
+}
+
+// ============================================================
+// JSON 提取
+// ============================================================
+
+/**
+ * 从 LLM 响应中提取 JSON 对象
+ * 容忍 markdown 代码块包裹、前后文字
+ */
+export function extractJSON(rawContent) {
+  const m = rawContent.match(/\{[\s\S]*\}/);
+  if (!m) return { parsed: null, error: 'No JSON object in response' };
+
+  try {
+    return { parsed: JSON.parse(m[0]), error: null };
+  } catch (e) {
+    return { parsed: null, error: e.message };
+  }
+}
+
+// ============================================================
+// 多轮分析
+// ============================================================
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+/**
+ * 多轮聚焦分析
+ * 每轮独立调用 API,中间结果写入缓存目录。已有缓存则跳过。
+ *
+ * @param {Object} opts
+ * @param {string} opts.imagePath       图片路径
+ * @param {Array}  opts.passes           轮次配置数组
+ *   [{ name: string, systemPrompt: string, userPrompt: string, maxTokens?: number }]
+ * @param {string} opts.cacheDir         缓存目录
+ * @param {string} [opts.model]          模型名
+ * @param {number} [opts.delayMs=1500]   轮次间延迟
+ * @returns {Promise<Array<{ pass: number, name: string, raw: string, parsed: object|null, error: string|null, usage: object|null }>>}
+ */
+export async function callMultiPass(opts) {
+  const { imagePath, passes, cacheDir, model, delayMs = 1500 } = opts;
+
+  if (!fs.existsSync(cacheDir)) {
+    fs.mkdirSync(cacheDir, { recursive: true });
+  }
+
+  const results = [];
+
+  for (let i = 0; i < passes.length; i++) {
+    const p = passes[i];
+    const passNum = i + 1;
+    const cacheFile = path.join(cacheDir, `pass${passNum}.json`);
+
+    // 检查缓存
+    if (fs.existsSync(cacheFile)) {
+      console.log(`  Pass ${passNum} (${p.name}): 已有缓存,跳过`);
+      results.push(JSON.parse(fs.readFileSync(cacheFile, 'utf-8')));
+      continue;
+    }
+
+    console.log(`  Pass ${passNum} (${p.name}, ${p.maxTokens || 2000}t)...`);
+    try {
+      const result = await analyze({
+        imagePath,
+        systemPrompt: p.systemPrompt,
+        userPrompt: p.userPrompt,
+        maxTokens: p.maxTokens,
+        model,
+      });
+      const entry = { pass: passNum, name: p.name, ...result };
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      results.push(entry);
+      console.log(`    ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
+    } catch (e) {
+      console.log(`    ✗ ${e.message}`);
+      const entry = { pass: passNum, name: p.name, error: e.message, parsed: null, usage: null };
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      results.push(entry);
+    }
+
+    if (i < passes.length - 1) await sleep(delayMs);
+  }
+
+  return results;
+}

+ 101 - 0
docs/REPOS-INDEX.md

@@ -0,0 +1,101 @@
+# FMODE 技能与规则索引(repos.fmode.cn)
+
+> 本索引是客户部署 Hermes Agent + Claude Code 容器后,需要初始化安装的**全部技能/插件/规则**清单。
+> 所有文件从 `https://repos.fmode.cn/` 下载(七牛 CDN,公网直连)。
+
+---
+
+## 一、插件(Plugin)— 核心能力底座
+
+### plugin-fmode(★ 必装)
+- **作用**:Fmode 生态用户插件。统一身份 token 语义、余额/配额查询、**微信扫码充值(余额不足自动弹充值二维码)**、订阅套餐、欠费兜底 hook、VOC 计费纪律。
+- **版本**:0.3.0
+- **下载**:https://repos.fmode.cn/x/fmode-skills/plugin-fmode/
+  - 入口:`plugin/plugin.yaml`、`plugin/fmode_core/core.py`
+- 依赖:FMODE_SESSION_TOKEN / FMODE_API_KEY 环境变量
+
+### plugin-session-async(建议)
+- **作用**:会话异步化,避免长任务阻塞。
+- **下载**:https://repos.fmode.cn/x/fmode-skills/plugin-session-async/
+
+### plugin-tips-cn(建议)
+- **作用**:中文提示/常用回复本地化。
+- **下载**:https://repos.fmode.cn/x/fmode-skills/plugin-tips-cn/
+
+---
+
+## 二、内部技能(Skill)— 运维与管理
+
+| 技能 | 作用 | 下载 |
+|---|---|---|
+| **skill-lifecycle** | 数字生命全生命周期:孵化/容器运维/舰队派发/定时任务/时区 | https://repos.fmode.cn/x/fmode-skills/skill-lifecycle/ |
+| **skill-fmode-platform** | Fmode 平台运维总入口:余额充值/计费/云函数/企微网关/验证码 | https://repos.fmode.cn/x/fmode-skills/skill-fmode-platform/ |
+| **skill-wecom** | 企业微信原生能力:待办/日程/语音/文档/升级SOP | https://repos.fmode.cn/x/fmode-skills/skill-wecom/ |
+| **skill-github** | Git 平台:Gogs/Gitea/GitHub 全流程 + 免密登录 | https://repos.fmode.cn/x/fmode-skills/skill-github/ |
+| **gogs-org-automation** | Gogs 企业组织自动化:建组织/加成员/DB直写 | https://repos.fmode.cn/x/fmode-skills/gogs-org-automation/ |
+| **newapi-billing-diagnostics** | new-api 计费诊断:403/余额/订阅 | https://repos.fmode.cn/x/fmode-skills/newapi-billing-diagnostics/ |
+| **dbo-server-ops** | dbo 生产服务器运维:免密链路/503诊断 | https://repos.fmode.cn/x/fmode-skills/dbo-server-ops/ |
+
+---
+
+## 三、公开技能(SkillHub 发布)— 业务能力
+
+| 技能 | 作用 | 获取方式 |
+|---|---|---|
+| fmode-account | 手机注册/登录/sessionToken | SkillHub: `fmode/fmode-skills` |
+| fmode-enterprise | 企业开通状态/独立部署 | 同上 |
+| fmode-cloud-functions | 云函数部署/调用 | 同上 |
+| fmode-cloud-resources | 华为云资源领取(STS) | 同上 |
+| fmode-static-publish | 静态站点发布(OBS+CDN) | 同上 |
+| fmode-git-archive | Git 归档 | 同上 |
+| fmode-storage | 对象存储公开链接 | 同上 |
+| fmode-vision | 图像识别 | 同上 |
+| fmode-listen | 录音转写 | 同上 |
+| fmode-pay | 微信支付进件 | 同上 |
+| fmode-voc-intelligence | VOC 用户经营洞察 | 同上 |
+| fmode-tihao | 达人研究 | 同上 |
+| fmode-product-lab | 新品研发实验室 | 同上 |
+| fmode-courseware | 课程课件生产 | 同上 |
+| fmode-report-publish | HTML 报告发布 | 同上 |
+
+> SkillHub 公开包:https://skillhub.cn/skills/fmode/fmode-skills
+> 代码仓库:https://git.fmode.cn/fmode/fmode-skills
+
+---
+
+## 四、规则(Rules)— Claude Code 行为准则
+
+安装到 `~/.claude/rules/` 或项目 `.claude/rules/`:
+
+| 规则 | 作用 | 下载 |
+|---|---|---|
+| 00-overview | 总览 | https://repos.fmode.cn/x/fmode-rules/claude/rules/00-overview.md |
+| 01-frontend | 前端规范 | .../01-frontend.md |
+| 02-fmode-ng | fmode-ng 框架 | .../02-fmode-ng.md |
+| 03-database-schema | 数据库Schema | .../03-database-schema.md |
+| 04-cloud-functions | 云函数规范 | .../04-cloud-functions.md |
+| 05-mock-data | Mock数据 | .../05-mock-data.md |
+| 06-deploy | 部署规范 | .../06-deploy.md |
+| 09-enterprise-agent-loop | 企业Agent循环(落盘/交付) | .../09-enterprise-agent-loop.md |
+| 10-enterprise-delivery-method | 企业交付方法 | .../10-enterprise-delivery-method.md |
+| functions | 云函数接口总纲 | .../functions.md |
+
+> 规则根目录:https://repos.fmode.cn/x/fmode-rules/claude/rules/
+
+---
+
+## 五、一步安装(推荐给 Agent 的长提示词片段)
+
+```bash
+# 在你的 Claude Code / Hermes 会话里执行:
+# 1) 建目录
+mkdir -p /opt/data/plugins /opt/data/skills /opt/data/repos /opt/data/logs /opt/data/sessions /opt/data/npm-global
+
+# 2) 拉取插件(fmode 核心)
+cd /opt/data/plugins
+curl -fsSL -o plugin-fmode.tar.gz https://repos.fmode.cn/x/fmode-skills/plugin-fmode.tar.gz
+```
+
+---
+
+*生成时间:2026-10-24 · 作者:FmodeAgent*

+ 18 - 7
package.json

@@ -1,13 +1,16 @@
 {
   "name": "fmode-skills",
+<<<<<<< HEAD
   "version": "0.3.0",
   "description": "Fmode 总技能库 —— 一次安装,全部 Fmode 技能自动落盘(数字分身初始化、云资源领取、前后端发布、VOC 情报、企微、支付等)。安装后由 installer.mjs 按宿主分发子技能。",
+=======
+  "version": "1.0.0",
+  "description": "Fmode 技能库源仓库(维护 + 打包)。dist/ 为上传 SkillHub 的产物,由 scripts/build-dist.mjs 从各技能源仓库拉取真实文件生成。",
+>>>>>>> 8bd0c5f (refactor: 重构为源库+打包架构 —— dist 由脚本从源仓库拉真实文件生成)
   "type": "module",
-  "main": "scripts/installer.mjs",
-  "bin": {
-    "fmode-skills": "scripts/installer.mjs"
-  },
+  "private": true,
   "license": "MPL-2.0",
+<<<<<<< HEAD
   "keywords": [
     "fmode",
     "harness",
@@ -20,11 +23,19 @@
     "云资源",
     "VOC"
   ],
+=======
+>>>>>>> 8bd0c5f (refactor: 重构为源库+打包架构 —— dist 由脚本从源仓库拉真实文件生成)
   "engines": {
     "node": ">=18"
   },
   "scripts": {
-    "install:all": "node scripts/installer.mjs --all",
-    "smoke": "node scripts/installer.mjs --check"
-  }
+    "build": "node scripts/build-dist.mjs",
+    "build:dry": "node scripts/build-dist.mjs --dry-run",
+    "verify": "node scripts/verify-dist.mjs",
+    "clean": "rm -rf dist"
+  },
+  "keywords": [
+    "fmode", "harness", "skillhub", "agent-skills",
+    "未来飞马", "技能库", "智能体技能", "数字分身"
+  ]
 }

+ 235 - 0
scripts/build-dist.mjs

@@ -0,0 +1,235 @@
+#!/usr/bin/env node
+/**
+ * build-dist.mjs —— 从各技能源仓库拉取真实源文件,生成 dist/(上传 SkillHub 的产物)
+ *
+ * 设计原则:
+ *   1. 本仓库(fmode-skills)只维护清单 + 映射 + 打包逻辑,不存技能正文
+ *   2. dist/ 每次构建全量重建,内含每个技能的真实 SKILL.md + 脚本 + 参考文档
+ *   3. 触发词(description 里的“触发词:”)随 SKILL.md 一起进入 dist,SkillHub 才能检索到
+ *
+ * 用法:
+ *   node scripts/build-dist.mjs              全量构建
+ *   node scripts/build-dist.mjs --dry-run    只检查映射和源可达性,不落盘
+ *   node scripts/build-dist.mjs --skill=xxx  只构建单个技能
+ *   node scripts/build-dist.mjs --offline    使用本地缓存,不访问远端
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { normalizeSkill } from './normalize-skill.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..');
+const DIST = path.join(ROOT, 'dist');
+const CACHE = path.join(ROOT, '.cache', 'repos');
+const SOURCES = JSON.parse(fs.readFileSync(path.join(ROOT, 'skill-sources.json'), 'utf8'));
+
+const args = process.argv.slice(2);
+const DRY = args.includes('--dry-run');
+const OFFLINE = args.includes('--offline');
+const ONLY = (args.find(a => a.startsWith('--skill=')) || '').split('=')[1] || null;
+
+const C = { g: '\x1b[32m', y: '\x1b[33m', r: '\x1b[31m', d: '\x1b[2m', b: '\x1b[1m', x: '\x1b[0m' };
+const log = (m) => console.log(m);
+const ok = (m) => log(`${C.g}✓${C.x} ${m}`);
+const warn = (m) => log(`${C.y}!${C.x} ${m}`);
+const err = (m) => log(`${C.r}✗${C.x} ${m}`);
+
+/** 需要排除的文件(不进 dist) */
+const EXCLUDE = [
+  /(^|\/)\.git(\/|$)/, /(^|\/)node_modules(\/|$)/, /(^|\/)\.cache(\/|$)/, /(^|\/)dist(\/|$)/,
+  /(^|\/)\.github(\/|$)/,
+  /^\.gitignore$/, /^package-lock\.json$/, /^\.DS_Store$/,
+  /\.bak$/, /\.bak-/, /\.log$/, /\.old$/, /~$/,
+];
+
+function shouldExclude(rel) {
+  return EXCLUDE.some(re => re.test(rel));
+}
+
+/** 递归复制目录,过滤排除项 */
+function copyDir(src, dest, stats, rootSrc) {
+  if (!fs.existsSync(src)) return;
+  const root = rootSrc || src;
+  fs.mkdirSync(dest, { recursive: true });
+  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
+    const s = path.join(src, entry.name);
+    const d = path.join(dest, entry.name);
+    const rel = path.relative(root, s).split(path.sep).join('/');
+    if (shouldExclude(rel) || shouldExclude(entry.name)) continue;
+    if (entry.isDirectory()) {
+      copyDir(s, d, stats, root);
+    } else if (entry.isFile()) {
+      fs.copyFileSync(s, d);
+      stats.files++;
+      stats.bytes += fs.statSync(s).size;
+    }
+  }
+}
+
+/** 从仓库拉取/更新源码到缓存目录 */
+function syncRepo(name, cfg) {
+  const cacheDir = path.join(CACHE, name);
+
+  if (cfg.status === 'local-source') {
+    const localDir = path.join(ROOT, 'sources', name);
+    if (!fs.existsSync(localDir)) {
+      return { ok: false, reason: `本地源目录不存在: sources/${name}` };
+    }
+    return { ok: true, dir: localDir };
+  }
+
+  if (cfg.status === 'local-repo') {
+    // 直接使用本机已 clone 的仓库(构建机本地验证用)
+    if (!fs.existsSync(cfg.localPath)) {
+      return { ok: false, reason: `本地仓库不存在: ${cfg.localPath}` };
+    }
+    return { ok: true, dir: cfg.localPath };
+  }
+
+  if (cfg.status === 'pending-source') {
+    return { ok: false, reason: '尚未建立源仓库(pending-source)', skip: true };
+  }
+
+  if (OFFLINE) {
+    if (fs.existsSync(cacheDir)) return { ok: true, dir: cacheDir, cached: true };
+    return { ok: false, reason: '离线模式且无缓存' };
+  }
+
+  try {
+    if (fs.existsSync(path.join(cacheDir, '.git'))) {
+      execFileSync('git', ['-C', cacheDir, 'fetch', '--depth=1', 'origin'], {
+        stdio: 'pipe', timeout: 60000,
+      });
+      execFileSync('git', ['-C', cacheDir, 'reset', '--hard', 'origin/HEAD'], {
+        stdio: 'pipe', timeout: 60000,
+      });
+    } else {
+      fs.mkdirSync(CACHE, { recursive: true });
+      execFileSync('git', ['clone', '--depth=1', cfg.repo, cacheDir], {
+        stdio: 'pipe', timeout: 120000,
+      });
+    }
+    return { ok: true, dir: cacheDir };
+  } catch (e) {
+    if (fs.existsSync(cacheDir)) {
+      return { ok: true, dir: cacheDir, stale: true, reason: '远端拉取失败,使用旧缓存' };
+    }
+    return { ok: false, reason: `clone 失败: ${String(e.message).slice(0, 120)}` };
+  }
+}
+
+/** 品牌映射:对外呈现统一为 FmodeAgent / FmodeCode
+ *  注意:只替换独立出现的品牌词,避免改动代码标识符(如 readClaudeSettingsEnv) */
+const BRAND_MAP = [
+  [/Hermes[\s-]?Agent/gi, 'FmodeAgent'],
+  [/(?<![A-Za-z])Hermes(?![A-Za-z])/g, 'FmodeAgent'],
+  [/Claude[\s-]?Code/gi, 'FmodeCode'],
+  [/(?<![A-Za-z])Claude(?![A-Za-z])/g, 'FmodeCode'],
+  [/(?<![A-Za-z])DeepSeek(?:-[vV][\d.]+\w*)?(?![A-Za-z])/g, 'FmodeCode'],
+  [/(?<![A-Za-z])GPT-?[45](?:\.\d+)?(?![A-Za-z])/g, 'FmodeCode'],
+  [/(?<![A-Za-z])OpenAI(?![A-Za-z])/g, 'Fmode'],
+];
+
+/** 对 dist 目录内文本文件做品牌替换(.md/.json/.mjs/.js/.txt) */
+function brandFixDir(dir) {
+  const TEXT_EXT = /\.(md|json|mjs|js|cjs|ts|txt|yaml|yml|html|htm|css|svg|xml)$/i;
+  const walk = (d) => {
+    for (const e of fs.readdirSync(d, { withFileTypes: true })) {
+      const p = path.join(d, e.name);
+      if (e.isDirectory()) { walk(p); continue; }
+      if (!TEXT_EXT.test(e.name)) continue;
+      let s = fs.readFileSync(p, 'utf8');
+      let changed = false;
+      for (const [re, to] of BRAND_MAP) {
+        const n = s.replace(re, to);
+        if (n !== s) { s = n; changed = true; }
+      }
+      if (changed) fs.writeFileSync(p, s, 'utf8');
+    }
+  };
+  walk(dir);
+}
+
+
+// ─────────────────────────── 主流程 ───────────────────────────
+
+const summary = { built: [], skipped: [], failed: [], files: 0, bytes: 0 };
+
+log(`\n${C.b}Fmode Skills 打包${C.x}  ${DRY ? '(dry-run)' : ''}${OFFLINE ? ' (offline)' : ''}\n`);
+
+if (!DRY) {
+  fs.rmSync(DIST, { recursive: true, force: true });
+  fs.mkdirSync(DIST, { recursive: true });
+}
+
+const names = ONLY ? [ONLY] : Object.keys(SOURCES.sources);
+
+for (const name of names) {
+  const cfg = SOURCES.sources[name];
+  if (!cfg) {
+    err(`${name} 不在映射表中`);
+    summary.failed.push({ name, reason: '未在 skill-sources.json 中定义' });
+    continue;
+  }
+
+  const synced = syncRepo(name, cfg);
+  if (!synced.ok) {
+    if (synced.skip) {
+      warn(`${name} 跳过 — ${synced.reason}`);
+      summary.skipped.push({ name, reason: synced.reason });
+    } else {
+      err(`${name} 失败 — ${synced.reason}`);
+      summary.failed.push({ name, reason: synced.reason });
+    }
+    continue;
+  }
+
+  const skillDir = path.join(synced.dir, cfg.path === '.' ? '' : cfg.path);
+
+  // 规范化:统一 frontmatter,自动补触发词(SkillHub 检索依赖)
+  const norm = normalizeSkill(skillDir, name);
+  if (!norm.ok) {
+    err(`${name} 规范化失败: ${norm.issues.join('; ')}`);
+    summary.failed.push({ name, reason: norm.issues.join('; ') });
+    continue;
+  }
+
+  if (!DRY) {
+    const targetDir = path.join(DIST, cfg.target);
+    copyDir(skillDir, targetDir, summary);
+    // 用规范化后的 SKILL.md 覆盖(正文保留原文,仅统一 frontmatter)
+    fs.writeFileSync(path.join(targetDir, 'SKILL.md'), norm.output, 'utf8');
+    // 品牌映射:对外呈现统一为 FmodeAgent / FmodeCode
+    brandFixDir(targetDir);
+  }
+
+  const trig = norm.meta?.triggers?.slice(0, 4).join('、') || '';
+  ok(`${name}${trig ? ` ${C.d}触发词: ${trig}${norm.meta.triggers.length > 4 ? '…' : ''}${C.x}` : ''}${synced.cached ? ` ${C.d}(缓存)${C.x}` : ''}${synced.stale ? ` ${C.y}(旧缓存)${C.x}` : ''}`);
+  summary.built.push(name);
+}
+
+// 生成 dist 索引
+if (!DRY && summary.built.length) {
+  const index = {
+    name: 'fmode-skills',
+    version: SOURCES.version,
+    builtAt: new Date().toISOString(),
+    skills: summary.built.map(name => ({
+      name,
+      path: SOURCES.sources[name].target,
+    })),
+  };
+  fs.writeFileSync(path.join(DIST, 'skills-index.json'), JSON.stringify(index, null, 2));
+}
+
+// 汇总
+log(`\n${C.b}汇总${C.x}`);
+log(`  构建成功: ${summary.built.length}`);
+if (summary.skipped.length) log(`  ${C.y}跳过: ${summary.skipped.length}${C.x}  ${summary.skipped.map(s => s.name).join(', ')}`);
+if (summary.failed.length) log(`  ${C.r}失败: ${summary.failed.length}${C.x}  ${summary.failed.map(f => f.name).join(', ')}`);
+if (!DRY) log(`  产出: ${summary.files} 文件 / ${(summary.bytes / 1024).toFixed(1)} KB → dist/`);
+
+process.exit(summary.failed.length > 0 && summary.built.length === 0 ? 1 : 0);

+ 197 - 0
scripts/normalize-skill.mjs

@@ -0,0 +1,197 @@
+#!/usr/bin/env node
+/**
+ * normalize-skill.mjs —— SKILL.md 规范化:把各仓库不同的 frontmatter 统一成 SkillHub 可检索格式
+ *
+ * 各源仓库现状(不统一):
+ *   - skill-storage:  name + description + keywords(无触发词)
+ *   - skill-deliverable: slug + summary + tags(无 name/description)
+ *   - 其他: 各种变体
+ *
+ * SkillHub 检索依赖:frontmatter 的 name + description,且 description 内需含触发词。
+ * 本模块负责:读入原始 SKILL.md → 输出统一格式(保留正文,补全 frontmatter)。
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+
+/** 解析 YAML frontmatter(轻量实现,只处理单行 key: value 与数组) */
+export function parseFrontmatter(content) {
+  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
+  if (!m) return { fm: {}, body: content };
+
+  const fm = {};
+  const lines = m[1].split(/\r?\n/);
+  let currentKey = null;
+
+  for (const line of lines) {
+    if (!line.trim()) continue;
+    // 数组项
+    if (/^\s*-\s+/.test(line) && currentKey) {
+      const val = line.replace(/^\s*-\s+/, '').trim();
+      if (!Array.isArray(fm[currentKey])) fm[currentKey] = [];
+      fm[currentKey].push(val);
+      continue;
+    }
+    // key: value
+    const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
+    if (kv) {
+      currentKey = kv[1];
+      let val = kv[2].trim();
+      // 多行字符串(| 或 >)简化处理:取后续行拼接
+      if (val === '|' || val === '>') {
+        fm[currentKey] = '';
+        continue;
+      }
+      // YAML 内联数组:[a, b, c]
+      if (val.startsWith('[') && val.endsWith(']')) {
+        fm[currentKey] = val
+          .slice(1, -1)
+          .split(',')
+          .map(s => s.trim().replace(/^["']|["']$/g, ''))
+          .filter(Boolean);
+        continue;
+      }
+      // 去引号
+      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+        val = val.slice(1, -1);
+      }
+      fm[currentKey] = val;
+    } else if (currentKey && typeof fm[currentKey] === 'string') {
+      // 多行描述续行
+      fm[currentKey] = (fm[currentKey] + ' ' + line.trim()).trim();
+    }
+  }
+
+  return { fm, body: content.slice(m[0].length) };
+}
+
+/** 从各字段中收集关键词 */
+function collectKeywords(fm) {
+  const out = new Set();
+  // 品牌词、层级词、通用词不入触发词
+  const BLOCK = /^(FmodeAgent|FmodeCode|Hermes(\s*Agent)?|Claude(\s*Code)?|Codex|GPT|DeepSeek|Agent|未来飞马|智能体技能|超级技能|平台基础设施|系统级|服务级|应用级|系统|服务|应用|skill|技能)$/i;
+
+  const push = (v) => {
+    if (!v) return;
+    const arr = Array.isArray(v) ? v : String(v).split(/[,,、\s]+/);
+    for (const item of arr) {
+      const t = String(item).trim();
+      // 过滤品牌名、通用词、过长项
+      if (!t || t.length > 20) continue;
+      if (BLOCK.test(t)) continue;
+      out.add(t);
+    }
+  };
+  push(fm.keywords);
+  push(fm.tags);
+  push(fm.trigger);
+  push(fm.triggers);
+  return [...out];
+}
+
+/** 从正文中提取一级/二级标题作为场景线索 */
+function collectHeadings(body) {
+  const out = [];
+  const re = /^#{1,2}\s+(.+)$/gm;
+  let m;
+  while ((m = re.exec(body)) !== null) {
+    const h = m[1].trim();
+    if (h.length > 1 && h.length < 30) out.push(h);
+    if (out.length >= 8) break;
+  }
+  return out;
+}
+
+/** 判断 description 里是否已有触发词标记 */
+function hasTrigger(desc) {
+  return /触发词[::]/.test(desc || '');
+}
+
+/**
+ * 规范化单个技能目录下的 SKILL.md
+ * @returns {{ok:boolean, issues:string[], output?:string, meta?:object}}
+ */
+export function normalizeSkill(skillDir, skillName) {
+  const skillMd = path.join(skillDir, 'SKILL.md');
+  if (!fs.existsSync(skillMd)) {
+    return { ok: false, issues: ['缺少 SKILL.md'] };
+  }
+
+  const raw = fs.readFileSync(skillMd, 'utf8');
+  const { fm, body } = parseFrontmatter(raw);
+
+  const issues = [];
+
+  // name:优先 name,其次 slug,最后用目录名
+  const name = fm.name || fm.slug || skillName;
+
+  // description:优先 description,其次 summary,最后从正文首段提取
+  let description = fm.description || fm.summary || '';
+  if (!description) {
+    const firstPara = body
+      .split(/\n\n+/)
+      .map(s => s.trim())
+      .find(s => s && !s.startsWith('#') && !s.startsWith('|') && !s.startsWith('>'));
+    description = firstPara ? firstPara.slice(0, 200) : `${skillName} 技能`;
+  }
+
+  // 触发词:已有则保留;没有则自动生成并追加
+  let triggers = [];
+  if (hasTrigger(description)) {
+    const tm = /触发词[::]\s*(.+?)(?:。|$)/.exec(description);
+    if (tm) triggers = tm[1].split(/[,,、\s]+/).filter(Boolean);
+  } else {
+    triggers = collectKeywords(fm);
+    if (triggers.length < 3) {
+      // 关键词不足时,用标题补
+      const heads = collectHeadings(body);
+      for (const h of heads) {
+        if (triggers.length >= 6) break;
+        if (!triggers.includes(h)) triggers.push(h);
+      }
+    }
+    if (triggers.length) {
+      description = description.replace(/\s+$/, '') + ` 触发词:${triggers.join('、')}`;
+    } else {
+      issues.push('无法生成触发词(keywords/tags/标题均不足)');
+    }
+  }
+
+  // 组装统一 frontmatter
+  const outFm = {
+    name,
+    description: description.replace(/\s+/g, ' ').trim(),
+    version: fm.version || fm.schema_version || '1.0.0',
+    license: fm.license || 'MPL-2.0',
+    author: 'Fmode (未来飞马)',
+  };
+
+  if (triggers.length) outFm.keywords = triggers.slice(0, 12);
+
+  const fmText = Object.entries(outFm)
+    .map(([k, v]) => {
+      if (Array.isArray(v)) return `${k}: [${v.join(', ')}]`;
+      return `${k}: ${JSON.stringify(v)}`;
+    })
+    .join('\n');
+
+  const output = `---\n${fmText}\n---\n${body}`;
+
+  return { ok: issues.length === 0, issues, output, meta: { name, triggers } };
+}
+
+/** CLI 用法:node normalize-skill.mjs <skillDir> <skillName> */
+if (process.argv[1] && process.argv[1].endsWith('normalize-skill.mjs')) {
+  const [dir, name] = process.argv.slice(2);
+  if (!dir) {
+    console.error('用法: node normalize-skill.mjs <skillDir> <skillName>');
+    process.exit(1);
+  }
+  const r = normalizeSkill(dir, name || path.basename(dir));
+  if (r.ok) {
+    console.log(r.output);
+  } else {
+    console.error('规范化失败:', r.issues.join('; '));
+    process.exit(1);
+  }
+}

+ 102 - 0
skill-sources.json

@@ -0,0 +1,102 @@
+{
+  "$comment": "技能源映射表 —— dist 打包唯一依据。path = 源仓库内技能根目录(含 SKILL.md 的那一层)。",
+  "version": "1.2.0",
+  "updatedAt": "2026-10-29",
+  "sources": {
+    "fmode-storage": {
+      "repo": "https://git.fmode.cn/fmode/skill-storage.git",
+      "path": "skills/fmode-storage",
+      "target": "skills/fmode-storage",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-storage"
+    },
+    "fmode-vision": {
+      "repo": "https://git.fmode.cn/fmode/skill-vision.git",
+      "path": "skills/skill-vision",
+      "target": "skills/fmode-vision",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-vision"
+    },
+    "fmode-listen": {
+      "repo": "https://git.fmode.cn/fmode/skill-listen.git",
+      "path": "skills/skill-listen",
+      "target": "skills/fmode-listen",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-listen"
+    },
+    "fmode-product-lab": {
+      "repo": "https://git.fmode.cn/fmode/skill-product-lab.git",
+      "path": "skills/skill-product-lab",
+      "target": "skills/fmode-product-lab",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-product-lab"
+    },
+    "fmode-image": {
+      "repo": "https://git.fmode.cn/fmode/skill-image.git",
+      "path": "skills/skill-image",
+      "target": "skills/fmode-image",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-image"
+    },
+    "fmode-report-publish": {
+      "repo": "https://git.fmode.cn/fmode/skill-report-html.git",
+      "path": ".",
+      "target": "skills/fmode-report-publish",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-report-html"
+    },
+    "fmode-heterarchy": {
+      "repo": "https://git.fmode.cn/fmode/skill-heterarchy.git",
+      "path": ".",
+      "target": "skills/fmode-heterarchy",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-heterarchy"
+    },
+    "fmode-deliverable": {
+      "repo": "https://git.fmode.cn/fmode/skill-deliverable.git",
+      "path": ".",
+      "target": "skills/fmode-deliverable",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-deliverable"
+    },
+    "fmode-agent-log": {
+      "repo": "https://git.fmode.cn/fmode/skill-agent-log-s3.git",
+      "path": ".",
+      "target": "skills/fmode-agent-log",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-agent-log-s3"
+    },
+    "fmode-video-lapian": {
+      "repo": "https://git.fmode.cn/fmode/skill-video-lapian.git",
+      "path": ".",
+      "target": "skills/fmode-video-lapian",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-video-lapian"
+    },
+    "fmode-video-editing": {
+      "repo": "https://git.fmode.cn/fmode/skill-video-editing.git",
+      "path": ".",
+      "target": "skills/fmode-video-editing",
+      "status": "local-repo",
+      "localPath": "/opt/data/git-repos/skill-video-editing"
+    },
+    "fmode-code-lit": {
+      "repo": "https://github.com/fmodecn/skill-code-lit.git",
+      "path": ".",
+      "target": "skills/fmode-code-lit",
+      "status": "repo"
+    },
+    "fmode-code-agent": {
+      "repo": "https://git.fmode.cn/fmode/fmode-code-agent.git",
+      "path": "skills/fmode-code-agent",
+      "target": "skills/fmode-code-agent",
+      "status": "repo",
+      "localPath": "/opt/data/git-repos/fmode-code-agent"
+    }
+  },
+  "notes": [
+    "path 指向仓库内含 SKILL.md 的那一层目录,最少见的是 skills/<name>/ 子目录结构",
+    "target 是 dist 内的目录名,统一用 fmode- 前缀对外呈现",
+    "新增技能:先在 Gogs 建源仓库 → 在本表登记 → 跑 npm run build"
+  ]
+}

+ 0 - 51
skills/fmode-account/SKILL.md

@@ -1,51 +0,0 @@
----
-slug: fmode-skill-fmode-account
-displayName: fmode-account
-version: 0.1.0
-name: fmode-account
-description: Fmode 账号注册登录:手机验证码 → sessionToken → 企业开通。数字分身初始化第一步。 触发词:注册、登录、验证码、sessionToken、账号、手机号、数字分身初始化、fmod账号
-tags: [FmodeAgent, FmodeCode, 服务级, account]
-platform: Both
-level: 服务级
-category: 平台基础设施
-icon: ⚙️
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# Fmode 账号(手机验证码注册/登录)
-
-## 触发词
-
-注册、登录、验证码、sessionToken、账号、手机号、数字分身初始化、fmod账号
-
-## 快速开始(ESM)
-
-```bash
-node scripts/index.mjs <action> '{"...": ...}'
-```
-
-或 Agent 直接按流程调用下述端点。
-
-## 端点清单
-
-| 端点 | 作用 |
-|------|------|
-| `POST https://server.fmode.cn/api/apig/message` | 发送验证码 {mobile,company} |
-| `GET https://server.fmode.cn/api/fmode/mobile` | 验证码登录 {mobile,code} → data.token/data.newapiToken |
-| `GET https://server.fmode.cn/parse/users/me` | 校验会话 Bearer token |
-
-## 关键纪律
-
-- ⚠️ token 走受保护 profile(0700),不进聊天/project/Git/日志
-- ⚠️ 验证码只用于立即登录请求
-- ⚠️ Windows 用当前用户目录,POSIX 0700/0600
-
-## References
-
-- 详见 `references/` 目录
-- 完整平台规范见 skill-core-guide(`fmode-skills` 自带)
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 5
skills/fmode-account/references/endpoints.md

@@ -1,5 +0,0 @@
-# Fmode 账号(手机验证码注册/登录) 端点参考
-
-| `POST https://server.fmode.cn/api/apig/message` | 发送验证码 {mobile,company} |
-| `GET https://server.fmode.cn/api/fmode/mobile` | 验证码登录 {mobile,code} → data.token/data.newapiToken |
-| `GET https://server.fmode.cn/parse/users/me` | 校验会话 Bearer token |

+ 0 - 30
skills/fmode-account/scripts/index.mjs

@@ -1,30 +0,0 @@
-#!/usr/bin/env node
-/**
- * Fmode 账号(手机验证码注册/登录) — 辅助脚本(零 npm 依赖,Node>=18 自带 fetch)
- * Copyright (c) 未来飞马  MPL-2.0
- *
- * 用法:node index.mjs <action> [jsonPayload]
- */
-const BASE = "https://server.fmode.cn";
-
-async function call(method, path, body, token) {
-  const headers = { "content-type": "application/json" };
-  if (token) headers["authorization"] = "Bearer " + token;
-  const res = await fetch(BASE + path, {
-    method, headers, body: body ? JSON.stringify(body) : undefined,
-  });
-  const text = await res.text();
-  // 脱敏输出:token/密钥不打印
-  const safe = text.replace(/(token|authorization|accessKey|secretKey|securityToken)["'\s:]+([^",}]+)/gi, "$1:***");
-  console.log(safe);
-  return res;
-}
-
-const [action, raw] = process.argv.slice(2);
-let payload = {};
-try { if (raw) payload = JSON.parse(raw); } catch { payload = { _raw: raw }; }
-
-// 示例端点: POST 发送验证码 {mobile,company}
-// 示例端点: GET 验证码登录 {mobile,code} → data.token/data.newapiToken
-// 示例端点: GET 校验会话 Bearer token
-

+ 0 - 49
skills/fmode-cloud-functions/SKILL.md

@@ -1,49 +0,0 @@
----
-slug: fmode-skill-fmode-cloud-functions
-displayName: fmode-cloud-functions
-version: 0.1.0
-name: fmode-cloud-functions
-description: Fmode 云函数部署与调用:Function 合同表存动态函数源,/api/functions 发布执行。 触发词:云函数、Function、/api/functions、后端函数、业务接口、动态函数
-tags: [FmodeAgent, FmodeCode, 服务级, cloud-functions]
-platform: Both
-level: 服务级
-category: 平台基础设施
-icon: ⚙️
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# Fmode 云函数(Function 合同 + /api/functions)
-
-## 触发词
-
-云函数、Function、/api/functions、后端函数、业务接口、动态函数
-
-## 快速开始(ESM)
-
-```bash
-node scripts/index.mjs <action> '{"...": ...}'
-```
-
-或 Agent 直接按流程调用下述端点。
-
-## 端点清单
-
-| 端点 | 作用 |
-|------|------|
-| `POST https://server.fmode.cn/api/functions` | 调用/注册云函数 {token, path, params} |
-
-## 关键纪律
-
-- ⚠️ CRUD 不建用户维护服务器,动态函数进 Function 合同表
-- ⚠️ handler(request,response) 签名 + 会话鉴权 + 租户校验 + 幂等
-- ⚠️ masterKey/dbURI/平台session 绝不进浏览器 bundle
-
-## References
-
-- 详见 `references/` 目录
-- 完整平台规范见 skill-core-guide(`fmode-skills` 自带)
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 3
skills/fmode-cloud-functions/references/endpoints.md

@@ -1,3 +0,0 @@
-# Fmode 云函数(Function 合同 + /api/functions) 端点参考
-
-| `POST https://server.fmode.cn/api/functions` | 调用/注册云函数 {token, path, params} |

+ 0 - 28
skills/fmode-cloud-functions/scripts/index.mjs

@@ -1,28 +0,0 @@
-#!/usr/bin/env node
-/**
- * Fmode 云函数(Function 合同 + /api/functions) — 辅助脚本(零 npm 依赖,Node>=18 自带 fetch)
- * Copyright (c) 未来飞马  MPL-2.0
- *
- * 用法:node index.mjs <action> [jsonPayload]
- */
-const BASE = "https://server.fmode.cn";
-
-async function call(method, path, body, token) {
-  const headers = { "content-type": "application/json" };
-  if (token) headers["authorization"] = "Bearer " + token;
-  const res = await fetch(BASE + path, {
-    method, headers, body: body ? JSON.stringify(body) : undefined,
-  });
-  const text = await res.text();
-  // 脱敏输出:token/密钥不打印
-  const safe = text.replace(/(token|authorization|accessKey|secretKey|securityToken)["'\s:]+([^",}]+)/gi, "$1:***");
-  console.log(safe);
-  return res;
-}
-
-const [action, raw] = process.argv.slice(2);
-let payload = {};
-try { if (raw) payload = JSON.parse(raw); } catch { payload = { _raw: raw }; }
-
-// 示例端点: POST 调用/注册云函数 {token, path, params}
-

+ 0 - 49
skills/fmode-cloud-resources/SKILL.md

@@ -1,49 +0,0 @@
----
-slug: fmode-skill-fmode-cloud-resources
-displayName: fmode-cloud-resources
-version: 0.1.0
-name: fmode-cloud-resources
-description: Fmode 云资源领取:平台签发项目级 STS,华为云 OBS 部署,无需用户华为账号。 触发词:云资源、领取、部署、华为云、STS、obs、huaweicloud、deploy
-tags: [FmodeAgent, FmodeCode, 服务级, cloud-resources]
-platform: Both
-level: 服务级
-category: 平台基础设施
-icon: ⚙️
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# Fmode 云资源(华为云 STS 部署)
-
-## 触发词
-
-云资源、领取、部署、华为云、STS、obs、huaweicloud、deploy
-
-## 快速开始(ESM)
-
-```bash
-node scripts/index.mjs <action> '{"...": ...}'
-```
-
-或 Agent 直接按流程调用下述端点。
-
-## 端点清单
-
-| 端点 | 作用 |
-|------|------|
-| `POST https://server.fmode.cn/api/apig/deploy/huaweicloud` | 取 STS {token, projectId} → accessKey/secretKey/securityToken/expiresAt/obsPath |
-
-## 关键纪律
-
-- ⚠️ STS 平台签发,非用户华为账号,用户无需华为账号/服务器
-- ⚠️ 过期(<5min)丢弃整套后重取,最多重试2次
-- ⚠️ 绝不替换为永久密钥
-
-## References
-
-- 详见 `references/` 目录
-- 完整平台规范见 skill-core-guide(`fmode-skills` 自带)
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 3
skills/fmode-cloud-resources/references/endpoints.md

@@ -1,3 +0,0 @@
-# Fmode 云资源(华为云 STS 部署) 端点参考
-
-| `POST https://server.fmode.cn/api/apig/deploy/huaweicloud` | 取 STS {token, projectId} → accessKey/secretKey/securityToken/expiresAt/obsPath |

+ 0 - 28
skills/fmode-cloud-resources/scripts/index.mjs

@@ -1,28 +0,0 @@
-#!/usr/bin/env node
-/**
- * Fmode 云资源(华为云 STS 部署) — 辅助脚本(零 npm 依赖,Node>=18 自带 fetch)
- * Copyright (c) 未来飞马  MPL-2.0
- *
- * 用法:node index.mjs <action> [jsonPayload]
- */
-const BASE = "https://server.fmode.cn";
-
-async function call(method, path, body, token) {
-  const headers = { "content-type": "application/json" };
-  if (token) headers["authorization"] = "Bearer " + token;
-  const res = await fetch(BASE + path, {
-    method, headers, body: body ? JSON.stringify(body) : undefined,
-  });
-  const text = await res.text();
-  // 脱敏输出:token/密钥不打印
-  const safe = text.replace(/(token|authorization|accessKey|secretKey|securityToken)["'\s:]+([^",}]+)/gi, "$1:***");
-  console.log(safe);
-  return res;
-}
-
-const [action, raw] = process.argv.slice(2);
-let payload = {};
-try { if (raw) payload = JSON.parse(raw); } catch { payload = { _raw: raw }; }
-
-// 示例端点: POST 取 STS {token, projectId} → accessKey/secretKey/securityToken/expiresAt/obsPath
-

+ 0 - 36
skills/fmode-courseware/SKILL.md

@@ -1,36 +0,0 @@
----
-slug: fmode-skill-fmode-courseware
-displayName: fmode-courseware
-version: 0.1.0
-name: fmode-courseware
-description: 课件/报告 HTML 生产标准:委派 FmodeCode、基准学习、素材归档、证据链、分层验收。 触发词:课件、课程、导入课、课纲、验收、skill-present
-tags: [FmodeAgent, FmodeCode, 应用级, courseware]
-platform: Both
-level: 应用级
-category: 平台基础设施
-icon: 🏢
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# 课程课件生产标准
-
-## 触发词
-
-课件、课程、导入课、课纲、验收、skill-present
-
-## 能力分解
-
-['skill-present-course-production', 'html-course-delivery', 'html-report-delivery']
-
-- 原始来源: skill-present 抽取
-
-## 使用规则
-
-- 用户只需要自然语言描述业务场景,不要强迫了解参数
-- 未明确要求真实采集前,先使用演示样例(sample);明确 live 时再检查凭证/余额
-- 首轮输出采用"初步判断、机会假设、待校准"口径,附证据
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 3
skills/fmode-courseware/references/source.md

@@ -1,3 +0,0 @@
-# 课程课件生产标准 参考
-
-- 原始来源: skill-present 抽取

+ 0 - 51
skills/fmode-enterprise/SKILL.md

@@ -1,51 +0,0 @@
----
-slug: fmode-skill-fmode-enterprise
-displayName: fmode-enterprise
-version: 0.1.0
-name: fmode-enterprise
-description: Fmode 企业状态:查询订阅、申请独立部署、轮询开通。数字分身企业开通。 触发词:企业、开通、订阅、enterprise、Company、独立部署、sub、trial
-tags: [FmodeAgent, FmodeCode, 服务级, enterprise]
-platform: Both
-level: 服务级
-category: 平台基础设施
-icon: ⚙️
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# Fmode 企业(开通状态/独立部署)
-
-## 触发词
-
-企业、开通、订阅、enterprise、Company、独立部署、sub、trial
-
-## 快速开始(ESM)
-
-```bash
-node scripts/index.mjs <action> '{"...": ...}'
-```
-
-或 Agent 直接按流程调用下述端点。
-
-## 端点清单
-
-| 端点 | 作用 |
-|------|------|
-| `POST https://server.fmode.cn/api/fmode/enterprise/subscription/status` | 查企业状态 {sessionToken} |
-| `POST https://server.fmode.cn/api/fmode/enterprise/subscription/trial-standalone` | 申请独立部署 {name,sessionToken} |
-| `POST https://server.fmode.cn/api/fmode/enterprise/subscription/provision/retry` | 重试开通(仅失败后) |
-
-## 关键纪律
-
-- ⚠️ 不在企业时问人名(非企业名)再trial-standalone
-- ⚠️ 仅 provision pending/running 时轮询状态
-- ⚠️ 绝不索要数据库URI
-
-## References
-
-- 详见 `references/` 目录
-- 完整平台规范见 skill-core-guide(`fmode-skills` 自带)
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 5
skills/fmode-enterprise/references/endpoints.md

@@ -1,5 +0,0 @@
-# Fmode 企业(开通状态/独立部署) 端点参考
-
-| `POST https://server.fmode.cn/api/fmode/enterprise/subscription/status` | 查企业状态 {sessionToken} |
-| `POST https://server.fmode.cn/api/fmode/enterprise/subscription/trial-standalone` | 申请独立部署 {name,sessionToken} |
-| `POST https://server.fmode.cn/api/fmode/enterprise/subscription/provision/retry` | 重试开通(仅失败后) |

+ 0 - 30
skills/fmode-enterprise/scripts/index.mjs

@@ -1,30 +0,0 @@
-#!/usr/bin/env node
-/**
- * Fmode 企业(开通状态/独立部署) — 辅助脚本(零 npm 依赖,Node>=18 自带 fetch)
- * Copyright (c) 未来飞马  MPL-2.0
- *
- * 用法:node index.mjs <action> [jsonPayload]
- */
-const BASE = "https://server.fmode.cn";
-
-async function call(method, path, body, token) {
-  const headers = { "content-type": "application/json" };
-  if (token) headers["authorization"] = "Bearer " + token;
-  const res = await fetch(BASE + path, {
-    method, headers, body: body ? JSON.stringify(body) : undefined,
-  });
-  const text = await res.text();
-  // 脱敏输出:token/密钥不打印
-  const safe = text.replace(/(token|authorization|accessKey|secretKey|securityToken)["'\s:]+([^",}]+)/gi, "$1:***");
-  console.log(safe);
-  return res;
-}
-
-const [action, raw] = process.argv.slice(2);
-let payload = {};
-try { if (raw) payload = JSON.parse(raw); } catch { payload = { _raw: raw }; }
-
-// 示例端点: POST 查企业状态 {sessionToken}
-// 示例端点: POST 申请独立部署 {name,sessionToken}
-// 示例端点: POST 重试开通(仅失败后)
-

+ 0 - 52
skills/fmode-git-archive/SKILL.md

@@ -1,52 +0,0 @@
----
-slug: fmode-skill-fmode-git-archive
-displayName: fmode-git-archive
-version: 0.1.0
-name: fmode-git-archive
-description: Fmode 项目归档:源码进 git.fmode.cn,建 Gogs 账号/个人 token/建仓/推送/验证 SHA。 触发词:归档、git、Gogs、Gitea、git.fmode.cn、仓库、源码入库、git archive
-tags: [FmodeAgent, FmodeCode, 服务级, git-archive]
-platform: Both
-level: 服务级
-category: 平台基础设施
-icon: ⚙️
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# Fmode Git 归档(Gitea + 源码入库)
-
-## 触发词
-
-归档、git、Gogs、Gitea、git.fmode.cn、仓库、源码入库、git archive
-
-## 快速开始(ESM)
-
-```bash
-node scripts/index.mjs <action> '{"...": ...}'
-```
-
-或 Agent 直接按流程调用下述端点。
-
-## 端点清单
-
-| 端点 | 作用 |
-|------|------|
-| `POST https://server.fmode.cn/api/functions` | Gogs admin proxy 建用户 {token,path:/gogs/admin/proxy,params:{method:POST,path:/admin/users,...}} |
-| `POST https://git.fmode.cn/api/v1/users/<username>/tokens` | 创建个人 token |
-| `POST https://git.fmode.cn/api/v1/user/repos` | 创建私有仓库 |
-
-## 关键纪律
-
-- ⚠️ 用户名强制 fmode-<phone>,请求值忽略占位
-- ⚠️ token 绝不进 remote URL,用 HTTPS + token 密码
-- ⚠️ 排除 .env/密钥/构建产物/日志
-- ⚠️ git ls-remote 验证远端 SHA,发布与归档分开报告
-
-## References
-
-- 详见 `references/` 目录
-- 完整平台规范见 skill-core-guide(`fmode-skills` 自带)
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 5
skills/fmode-git-archive/references/endpoints.md

@@ -1,5 +0,0 @@
-# Fmode Git 归档(Gitea + 源码入库) 端点参考
-
-| `POST https://server.fmode.cn/api/functions` | Gogs admin proxy 建用户 {token,path:/gogs/admin/proxy,params:{method:POST,path:/admin/users,...}} |
-| `POST https://git.fmode.cn/api/v1/users/<username>/tokens` | 创建个人 token |
-| `POST https://git.fmode.cn/api/v1/user/repos` | 创建私有仓库 |

+ 0 - 30
skills/fmode-git-archive/scripts/index.mjs

@@ -1,30 +0,0 @@
-#!/usr/bin/env node
-/**
- * Fmode Git 归档(Gitea + 源码入库) — 辅助脚本(零 npm 依赖,Node>=18 自带 fetch)
- * Copyright (c) 未来飞马  MPL-2.0
- *
- * 用法:node index.mjs <action> [jsonPayload]
- */
-const BASE = "https://server.fmode.cn";
-
-async function call(method, path, body, token) {
-  const headers = { "content-type": "application/json" };
-  if (token) headers["authorization"] = "Bearer " + token;
-  const res = await fetch(BASE + path, {
-    method, headers, body: body ? JSON.stringify(body) : undefined,
-  });
-  const text = await res.text();
-  // 脱敏输出:token/密钥不打印
-  const safe = text.replace(/(token|authorization|accessKey|secretKey|securityToken)["'\s:]+([^",}]+)/gi, "$1:***");
-  console.log(safe);
-  return res;
-}
-
-const [action, raw] = process.argv.slice(2);
-let payload = {};
-try { if (raw) payload = JSON.parse(raw); } catch { payload = { _raw: raw }; }
-
-// 示例端点: POST Gogs admin proxy 建用户 {token,path:/gogs/admin/proxy,params:{method:POST,path:/admin/users,...}}
-// 示例端点: POST 创建个人 token
-// 示例端点: POST 创建私有仓库
-

+ 0 - 34
skills/fmode-listen/SKILL.md

@@ -1,34 +0,0 @@
----
-slug: fmode-skill-fmode-listen
-displayName: fmode-listen
-version: 0.1.2
-name: fmode-listen
-description: 录音/视频音轨转文字(讯飞 LFASR × Fmode 网关),多语种/方言/说话人分离,服务端计费。 触发词:转写、录音、语音转文字、ASR、讯飞、说话人分离、meeting转写
-tags: [FmodeAgent, FmodeCode, 服务级, listen]
-platform: Both
-level: 服务级
-category: 平台基础设施
-icon: 🛠️
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# 录音转写
-
-## 触发词
-转写、录音、语音转文字、ASR、讯飞、说话人分离、meeting转写
-
-## 快速开始
-```bash
-node scripts/index.mjs <action>
-```
-或按 npm fmode-listen 的官方命令(`npx --yes fmode-listen@latest workspace`)。
-
-## 端点
-- POST https://server.fmode.cn/api/listen/transcribe
-
-## 来源
-- npm fmode-listen
-
-## License
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 3
skills/fmode-listen/references/source.md

@@ -1,3 +0,0 @@
-# 来源
-npm fmode-listen
-端点: POST https://server.fmode.cn/api/listen/transcribe

+ 0 - 36
skills/fmode-pay/SKILL.md

@@ -1,36 +0,0 @@
----
-slug: fmode-skill-fmode-pay
-displayName: fmode-pay
-version: 0.1.0
-name: fmode-pay
-description: 微信服务商特约商户进件:资料收集 → APIv3 签名 → 云函数提交 → 二维码签约 → 归档。 触发词:微信商户、进件、特约商户、fmode-pay、商户开通、支付开通
-tags: [FmodeAgent, FmodeCode, 应用级, pay]
-platform: Both
-level: 应用级
-category: 平台基础设施
-icon: 🏢
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# 微信特约商户自动化进件
-
-## 触发词
-
-微信商户、进件、特约商户、fmode-pay、商户开通、支付开通
-
-## 能力分解
-
-['wxpay-merchant-apply', 'wxpay-merchant-status', 'wxpay-merchant-upload', 'wxpay-merchant-archive']
-
-- 原始来源: 本次 fmode-pay 开发
-
-## 使用规则
-
-- 用户只需要自然语言描述业务场景,不要强迫了解参数
-- 未明确要求真实采集前,先使用演示样例(sample);明确 live 时再检查凭证/余额
-- 首轮输出采用"初步判断、机会假设、待校准"口径,附证据
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 3
skills/fmode-pay/references/source.md

@@ -1,3 +0,0 @@
-# 微信特约商户自动化进件 参考
-
-- 原始来源: 本次 fmode-pay 开发

+ 0 - 36
skills/fmode-product-lab/SKILL.md

@@ -1,36 +0,0 @@
----
-slug: fmode-skill-fmode-product-lab
-displayName: fmode-product-lab
-version: 0.2.0
-name: fmode-product-lab
-description: 新品研发:VOC 采集 + KANO + 市场分析 + 竞争 + 定位 + 错位竞争全流程。 触发词:新品研发、产品开发、健康快消、VOC、KANO、市场分析、定位、竞品
-tags: [FmodeAgent, FmodeCode, 应用级, product-lab]
-platform: Both
-level: 应用级
-category: 平台基础设施
-icon: 🏢
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# 新品研发实验室
-
-## 触发词
-
-新品研发、产品开发、健康快消、VOC、KANO、市场分析、定位、竞品
-
-## 能力分解
-
-['product-lab-workflow']
-
-- 原始来源: npm fmode-product-lab 0.2.0
-
-## 使用规则
-
-- 用户只需要自然语言描述业务场景,不要强迫了解参数
-- 未明确要求真实采集前,先使用演示样例(sample);明确 live 时再检查凭证/余额
-- 首轮输出采用"初步判断、机会假设、待校准"口径,附证据
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 3
skills/fmode-product-lab/references/source.md

@@ -1,3 +0,0 @@
-# 新品研发实验室 参考
-
-- 原始来源: npm fmode-product-lab 0.2.0

+ 0 - 36
skills/fmode-report-publish/SKILL.md

@@ -1,36 +0,0 @@
----
-slug: fmode-skill-fmode-report-publish
-displayName: fmode-report-publish
-version: 0.1.0
-name: fmode-report-publish
-description: 网页报告交付:手机侧边栏 + 区块化 + 数据优先,交付到 S3 公开 URL。 触发词:出报告、HTML报告、可视化报告、S3、报告交付、发布链接
-tags: [FmodeAgent, FmodeCode, 应用级, report-publish]
-platform: Both
-level: 应用级
-category: 平台基础设施
-icon: 🏢
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# HTML 报告制作发布
-
-## 触发词
-
-出报告、HTML报告、可视化报告、S3、报告交付、发布链接
-
-## 能力分解
-
-['html-report-delivery', 'fmode-storage']
-
-- 原始来源: Hermes html-report-delivery 抽取
-
-## 使用规则
-
-- 用户只需要自然语言描述业务场景,不要强迫了解参数
-- 未明确要求真实采集前,先使用演示样例(sample);明确 live 时再检查凭证/余额
-- 首轮输出采用"初步判断、机会假设、待校准"口径,附证据
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

+ 0 - 3
skills/fmode-report-publish/references/source.md

@@ -1,3 +0,0 @@
-# HTML 报告制作发布 参考
-
-- 原始来源: Hermes html-report-delivery 抽取

+ 0 - 51
skills/fmode-static-publish/SKILL.md

@@ -1,51 +0,0 @@
----
-slug: fmode-skill-fmode-static-publish
-displayName: fmode-static-publish
-version: 0.1.0
-name: fmode-static-publish
-description: Fmode 前后端发布:构建 → 注册 Project → STS → obsutil 同步 OBS → CDN 刷新 → 验证公开 URL。 触发词:发布、部署、上线、静态站、前端、课件、CDN、obsutil、public URL、huaweicloud-cdn-refresh
-tags: [FmodeAgent, FmodeCode, 服务级, static-publish]
-platform: Both
-level: 服务级
-category: 平台基础设施
-icon: ⚙️
-license: MPL-2.0
-author: Fmode (未来飞马)
----
-
-# Fmode 静态发布(OBS+CDN 公开 URL)
-
-## 触发词
-
-发布、部署、上线、静态站、前端、课件、CDN、obsutil、public URL、huaweicloud-cdn-refresh
-
-## 快速开始(ESM)
-
-```bash
-node scripts/index.mjs <action> '{"...": ...}'
-```
-
-或 Agent 直接按流程调用下述端点。
-
-## 端点清单
-
-| 端点 | 作用 |
-|------|------|
-| `POST https://server.fmode.cn/api/functions` | 注册 Project {token,path:/fmode/project-register,params:{name,version,gitUrl}} |
-| `POST https://server.fmode.cn/api/apig/deploy/huaweicloud` | 取 STS {token,projectId} |
-| `POST https://server.fmode.cn/api/apig/deploy/huaweicloud-cdn-refresh` | CDN 刷新 {token,projectId,path} |
-
-## 关键纪律
-
-- ⚠️ 注册Project→构建→扫token→取STS→上传obsPath→CDN刷新→验证URL 逐门禁报告
-- ⚠️ obsPath 必须是 dev/<projectId>/
-- ⚠️ 会话过期先重播登录再取新STS,不求华为凭据
-
-## References
-
-- 详见 `references/` 目录
-- 完整平台规范见 skill-core-guide(`fmode-skills` 自带)
-
-## License
-
-MPL-2.0 © 2026 Fmode (未来飞马)

Vissa filer visades inte eftersom för många filer har ändrats