Просмотр исходного кода

skill-task-progress v0.1.0: 任务进度四态上报+成果交付入库——三表初始化(幂等PUT补列)+progress四态心跳+deliver幂等upsert+status双视角; Parse Date __type坑实测沉淀(schema 111双向验证); 凭据四级解析零硬编码

liuyuyang 1 неделя назад
Сommit
ef3221abd5

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+node_modules/
+*.log
+.env
+__pycache__/

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 fmodecn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 64 - 0
README.md

@@ -0,0 +1,64 @@
+# skill-task-progress · 任务进度+成果上报 📊
+
+> **Agent 干活进度与成果交付,即刻进入 FmodeAgent 平台(App 四 Tab / 看板实时可见)**——初始化三表 + 进度四态上报 + 交付链接入库。
+> 来源:FmodeAgent 集群实战验证的「任务进度上报+成果交付上报」能力(agent-status-reporter 家族封装)。
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+
+## 解决什么
+
+Agent 在容器里埋头干活,主人看不到进度、成果散落各处没人沉淀。本技能把三件事变成三条命令:
+
+```
+Agent 上报 → Parse 表 → App 四 Tab / 看板(scope 三视角 mine/company/all)
+  ① init-tables   初始化/校验 FmodeAgent / AgentTaskStatus / AgentDeliverable 三表
+  ② progress      任务四态 ack→running→done/failed + 30s 心跳(无心跳=疑似死亡主动查)
+  ③ deliver       交付即报:交付物+链接上报入库(幂等覆盖,漏报即补)
+  ④ status        查询本 agent 最近任务 / 全集群视角
+```
+
+## 快速用
+
+```bash
+node skills/fmode-task-progress/scripts/init-tables.mjs
+node skills/fmode-task-progress/scripts/progress.mjs ack --agent my-agent --task t1 --name "课纲修订"
+node skills/fmode-task-progress/scripts/progress.mjs running --agent my-agent --task t1
+node skills/fmode-task-progress/scripts/deliver.mjs --agent my-agent --task t1 \
+  --title "课纲 v1.0.6" --url "https://s3.fmode.cn/xxx/index.html" --type courseware
+node skills/fmode-task-progress/scripts/status.mjs mine --agent my-agent
+```
+
+## 凭据(零硬编码)
+
+四级解析:环境变量 `PARSE_APP_ID`/`PARSE_MASTER_KEY` → `~/.fmode/config.json` parse 段 → `future-server/config/config.nova.json` → 报错指引退出。**密钥绝不入仓**。
+
+## 已知坑(实测沉淀)
+
+- **Parse Date 字段必须 `{"__type":"Date","iso":"..."}` 包装**,裸字符串报 schema 111——脚本已统一封装 `wrapDate()`(详见 SKILL.md 坑章节)。
+
+## 上报纪律
+
+- **交付即报**:每章/每版/每次部署/每轮大修各一条,漏报即补(`--delivered-at` 补录)
+- **四态+心跳**:running 30s 无心跳=疑似死亡,调度层主动查
+- **done 的前提是验收过**:只收 DONE 标记未独立验证不报 done
+
+## 各工具安装
+
+### Hermes Agent
+```bash
+git clone https://git.fmode.cn/fmode/skill-task-progress.git
+cp -r skill-task-progress/skills/fmode-task-progress ~/.hermes/skills/
+```
+
+### Claude Code / Codex
+```bash
+git clone https://github.com/fmodecn/skill-task-progress.git
+cp -r skill-task-progress/skills/fmode-task-progress ~/.claude/skills/   # 或 ~/.codex/skills/
+```
+
+### WorkBuddy / 其他
+复制 `skills/fmode-task-progress` 到对应技能目录,读 SKILL.md 按工具规范并入。
+
+## License
+
+MIT

+ 13 - 0
package.json

@@ -0,0 +1,13 @@
+{
+  "name": "fmode-task-progress",
+  "version": "0.1.0",
+  "description": "Claude Code skill: 任务进度四态上报(ack→running→done/failed+心跳)+成果交付入库(deliverable链接幂等上报)——FmodeAgent 平台 App/看板实时可见。零硬编码凭据四级解析。",
+  "license": "MIT",
+  "bin": {
+    "fmode-task-progress": "./skills/fmode-task-progress/scripts/progress.mjs"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+ssh://git@github.com/fmodecn/skill-task-progress.git"
+  }
+}

+ 124 - 0
skills/fmode-task-progress/SKILL.md

@@ -0,0 +1,124 @@
+---
+name: fmode-task-progress
+description: "任务进度四态上报+成果交付入库:init-tables 初始化 FmodeAgent/AgentTaskStatus/AgentDeliverable 三表,progress 上报 ack→running→done/failed+心跳,deliver 把交付链接上报入库(幂等覆盖),status 查询本 agent/全集群视角。适用:(1) Agent 干活要让平台 App/看板实时可见 (2) 任务有交付物需交付即报 (3) 集群进度巡检。"
+description_en: "Task progress (4-state) reporting + deliverable registration: init FmodeAgent/AgentTaskStatus/AgentDeliverable tables, report ack→running→done/failed + heartbeat, register deliverable URLs (idempotent upsert), query mine/all scope. Triggers: task progress, deliverable reporting, agent dashboard."
+---
+
+# Fmode Task Progress — 任务进度+成果上报技能
+
+> 装上本技能,你的 Agent 干活进度与成果交付即刻进入 FmodeAgent 平台(App 四 Tab / 看板实时可见)。
+> 来源:FmodeAgent 集群实战验证的「任务进度上报+成果交付上报」能力(agent-status-reporter 家族)。
+
+## 一、快速开始
+
+```bash
+# 1. 初始化/校验三表(幂等,可重跑)
+node skills/fmode-task-progress/scripts/init-tables.mjs
+
+# 2. 任务四态上报
+node scripts/progress.mjs ack     --agent my-agent --task task-001 --name "课纲修订"
+node scripts/progress.mjs running --agent my-agent --task task-001
+node scripts/progress.mjs done    --agent my-agent --task task-001 --note "已验收"
+# 失败时
+node scripts/progress.mjs failed  --agent my-agent --task task-001 --note "API 403,待充值"
+
+# 3. running 心跳保鲜(30s 无心跳=疑似死亡,调度层会主动查)
+node scripts/progress.mjs heartbeat --agent my-agent --task task-001
+
+# 4. 交付即报(有交付物+链接就立即报,每章/每版/每次部署各一条)
+node scripts/deliver.mjs --agent my-agent --task task-001 \
+  --title "课纲 v1.0.6" --url "https://s3.fmode.cn/xxx/index.html" \
+  --type courseware --project warmup --summary "修订完成已验收"
+
+# 5. 查询
+node scripts/status.mjs mine --agent my-agent     # 本 agent 最近任务+交付
+node scripts/status.mjs all                       # 全集群(masterKey 视角)
+```
+
+## 二、凭据链(零硬编码)
+
+四级解析(`parse-client.mjs` 统一实现,找不到时报指引退出,绝不猜):
+
+| 级 | 来源 | 场景 |
+|---|------|------|
+| 1 | 环境变量 `PARSE_APP_ID` / `PARSE_MASTER_KEY`(`PARSE_SERVER_URL` 可覆盖地址) | CI/容器 |
+| 2 | `~/.fmode/config.json` → `parse` 段 `{appId, masterKey}` | 个人开发机 |
+| 3 | `future-server/config/config.nova.json` → `parse` 段 | 平台开发机标准位置 |
+| 4 | 以上皆无 → 打印配置指引,退出码 2 | —— |
+
+**密钥红线**:config.nova.json 凭据绝不入 git;本技能脚本零硬编码,凭据只在运行时读取。
+
+## 三、三张表
+
+| 表 | 作用 | 关键字段 |
+|---|------|---------|
+| `FmodeAgent` | Agent 注册+心跳 | agentId / soulSummary / containerRef / lastHeartbeat(Date) / status / scope |
+| `AgentTaskStatus` | 任务四态 | agentId / taskId / taskName / status(ack\|running\|done\|failed) / startedAt / endedAt / heartbeatAt / resultNote |
+| `AgentDeliverable` | 成果 | agentId / taskId / title / project / deliverableType(report\|code\|courseware\|data) / url / summary / deliveredAt(Date) |
+
+数据流:`Agent 上报 → Parse 表 → App 四 Tab / 看板(scope 三视角 mine/company/all)`。
+
+## 四、★ Parse Date 字段铁律(schema 111 坑,已实测验证)
+
+写 `Date` 类型字段(`lastHeartbeat`/`startedAt`/`endedAt`/`heartbeatAt`/`deliveredAt`)时:
+
+```bash
+# ✗ 错误:裸字符串 → Parse 报 code=111 schema 不匹配
+{"startedAt": "2026-09-12T08:00:00.000Z"}
+
+# ✓ 正确:__type Date 包装
+{"startedAt": {"__type": "Date", "iso": "2026-09-12T08:00:00.000Z"}}
+```
+
+实测(2026-09-12,server.fmode.cn/parse):
+- 裸字符串写 `AgentTaskStatus.startedAt` → `code=111`("Expected Date" 类错误)
+- 包装后写 → 成功入库,查询返回 `{"__type":"Date","iso":"..."}`
+
+本技能所有脚本统一走 `parse-client.mjs` 的 `wrapDate()`,**自己拼 JSON 直连 REST 时必须记得包装**。
+同类包装:Pointer 字段 `{"__type":"Pointer","className":"Company","objectId":"xxx"}`。
+
+## 五、上报纪律(★ 核心章节)
+
+**交付即报**:
+- 任务节点有明确交付物+链接 → **立即上报**(每章/每版/每次部署/每轮大修各一条)
+- **漏报即补**:发现漏报马上用 `--delivered-at` 补录真实交付时间
+- 幂等保障:同 agentId+taskId+deliverableType 覆盖更新,补报不产生重复条目
+
+**进度四态+心跳**:
+
+| 状态 | 触发 | 记录 |
+|---|------|------|
+| ack | 接任务回执 | taskName / startedAt |
+| running | 开工 | heartbeatAt 保鲜 |
+| done | 完成+验收过 | endedAt + resultNote |
+| failed | 失败/超时/验收不过 | endedAt + 根因(不掩盖) |
+| heartbeat | running 期间每 30s | 只刷 heartbeatAt |
+
+- **30s 无心跳 = 疑似死亡**:调度层主动查进程与产物,不靠"以为还在跑"
+- **done 的前提是验收过**:只收到 DONE 标记但未独立验证 → 不报 done
+
+## 六、多工具安装
+
+### Hermes Agent
+```bash
+git clone https://git.fmode.cn/fmode/skill-task-progress.git
+cp -r skill-task-progress/skills/fmode-task-progress ~/.hermes/skills/
+```
+
+### Claude Code / Codex
+```bash
+git clone https://github.com/fmodecn/skill-task-progress.git
+cp -r skill-task-progress/skills/fmode-task-progress ~/.claude/skills/   # 或 ~/.codex/skills/
+```
+
+### WorkBuddy / 其他
+复制 `skills/fmode-task-progress` 到对应技能目录,README 按工具规范并入。
+
+## 七、故障对照
+
+| 症状 | 根因 | 对策 |
+|---|------|------|
+| `code=111` schema 错 | Date 字段裸字符串 | 走 `wrapDate()`(本技能脚本已内置),手拼 JSON 记得 `__type` 包装 |
+| 凭据报错退出码 2 | 四级链全空 | 按报错指引配 env 或 `~/.fmode/config.json` parse 段 |
+| 重复成果条目 | 未用幂等覆盖键 | deliver.mjs 按 agentId+taskId+deliverableType upsert,勿绕过 |
+| 查询 403 | REST masterKey 不在环境 | 走四级凭据链,勿用过期 sessionToken |

+ 85 - 0
skills/fmode-task-progress/scripts/deliver.mjs

@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+/**
+ * deliver.mjs — 成果交付上报(AgentDeliverable 表)
+ *
+ * 交付即报纪律:任务节点有明确交付物+链接 → 立即上报
+ *   (每章/每版/每次部署/每轮大修各一条;漏报即补)。
+ * 幂等:同 agentId+taskId+deliverableType 覆盖更新而非新增。
+ * Date 坑:deliveredAt 一律经 wrapDate() 包装为 {"__type":"Date","iso":...}
+ *   —— 裸字符串写 Parse 报 schema 111(见 SKILL.md 坑章节)。
+ *
+ * 用法:
+ *   node deliver.mjs --agent <agentId> --task <taskId> --title <标题> --url <交付链接> \
+ *        [--type report|code|courseware|data] [--project <项目>] [--summary <一句话>] \
+ *        [--delivered-at "2026-09-12T08:00:00.000Z"]
+ */
+
+import {
+  resolveCredentials, failNoCredentials, wrapDate, upsertObject,
+} from './parse-client.mjs';
+
+const TABLE = 'AgentDeliverable';
+const VALID_TYPES = ['report', 'code', 'courseware', 'data'];
+
+function parseArgs(argv) {
+  const args = {};
+  for (let i = 0; i < argv.length; i += 2) {
+    const key = (argv[i] || '').replace(/^--/, '');
+    args[key] = argv[i + 1];
+  }
+  return args;
+}
+
+const args = parseArgs(process.argv.slice(2));
+const missing = ['agent', 'task', 'title', 'url'].filter((k) => !args[k]);
+if (missing.length > 0) {
+  console.error(`[task-progress] 缺必填参数: --${missing.join(' --')}`);
+  console.error('用法: node deliver.mjs --agent <agentId> --task <taskId> --title <标题> --url <交付链接> [--type report|code|courseware|data] [--project <项目>] [--summary <一句话>] [--delivered-at ISO日期]');
+  process.exit(1);
+}
+if (args.type && !VALID_TYPES.includes(args.type)) {
+  console.error(`[task-progress] --type 必须是 ${VALID_TYPES.join('|')} 之一`);
+  process.exit(1);
+}
+
+const cred = resolveCredentials();
+if (!cred) failNoCredentials();
+
+// Date __type 包装(schema 111 坑的防线)
+let deliveredAt;
+try {
+  deliveredAt = wrapDate(args.deliveredAt || new Date().toISOString());
+} catch (e) {
+  console.error(`[task-progress] --delivered-at ${e.message}`);
+  process.exit(1);
+}
+
+const where = {
+  agentId: args.agent,
+  taskId: args.task,
+  deliverableType: args.type || 'report',
+};
+
+const data = {
+  agentId: args.agent,
+  taskId: args.task,
+  title: args.title,
+  url: args.url,
+  deliveredAt,
+};
+if (args.type) data.deliverableType = args.type;
+if (args.project) data.project = args.project;
+if (args.summary) data.summary = args.summary;
+
+const r = await upsertObject(TABLE, cred, where, data);
+console.log(JSON.stringify({
+  ok: true,
+  op: 'deliver',
+  agentId: args.agent,
+  taskId: args.task,
+  title: args.title,
+  url: args.url,
+  objectId: r.objectId,
+  created: r.created,
+  deliveredAt,
+}));

+ 95 - 0
skills/fmode-task-progress/scripts/init-tables.mjs

@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+/**
+ * init-tables.mjs — 初始化 FmodeAgent / AgentTaskStatus / AgentDeliverable 三表
+ *
+ * 真连 Parse REST /schemas 建(或校验)表结构,幂等可重跑:
+ *   - 表不存在 → 创建
+ *   - 字段已存在 → 忽略(Parse schema API 幂等)
+ * 建完逐表回读 schema 校验关键列,输出索引提示。
+ *
+ * 用法: node init-tables.mjs [--server <url>]
+ * 凭据: 四级解析(见 parse-client.mjs 头注释)
+ */
+
+import {
+  resolveCredentials, failNoCredentials, getSchema, createSchema,
+} from './parse-client.mjs';
+
+const argServer = process.argv.includes('--server')
+  ? process.argv[process.argv.indexOf('--server') + 1] : null;
+
+const cred = resolveCredentials();
+if (!cred) failNoCredentials();
+if (argServer) cred.serverURL = argServer;
+
+// 三表 schema(与 agent-status-reporter 平台侧既有结构对齐)
+const TABLES = {
+  FmodeAgent: {
+    agentId: { type: 'String' },
+    soulSummary: { type: 'String' },
+    containerRef: { type: 'String' },
+    lastHeartbeat: { type: 'Date' },
+    status: { type: 'String' },
+    scope: { type: 'String' },
+  },
+  AgentTaskStatus: {
+    agentId: { type: 'String' },
+    taskId: { type: 'String' },
+    taskName: { type: 'String' },
+    status: { type: 'String' }, // ack | running | done | failed
+    startedAt: { type: 'Date' },
+    endedAt: { type: 'Date' },
+    heartbeatAt: { type: 'Date' },
+    resultNote: { type: 'String' },
+  },
+  AgentDeliverable: {
+    agentId: { type: 'String' },
+    taskId: { type: 'String' },
+    title: { type: 'String' },
+    project: { type: 'String' },
+    deliverableType: { type: 'String' }, // report | code | courseware | data
+    url: { type: 'String' },
+    summary: { type: 'String' },
+    deliveredAt: { type: 'Date' },
+  },
+};
+
+// 查询索引提示(Parse REST 不直接建索引,平台侧 mongo 手工建;此处只提示)
+const INDEX_HINTS = {
+  FmodeAgent: 'agentId',
+  AgentTaskStatus: 'agentId + taskId 复合查询(status.mjs / progress.mjs 幂等键)',
+  AgentDeliverable: 'agentId + taskId + deliverableType(deliver.mjs 幂等覆盖键)',
+};
+
+const results = [];
+for (const [table, fields] of Object.entries(TABLES)) {
+  const before = await getSchema(table, cred); // eslint-disable-line no-await-in-loop
+  if (!before) {
+    await createSchema(table, cred, fields); // eslint-disable-line no-await-in-loop
+  } else {
+    await createSchema(table, cred, fields); // 幂等补列(已存在列忽略)// eslint-disable-line no-await-in-loop
+  }
+  // 回读校验关键列
+  const after = await getSchema(table, cred); // eslint-disable-line no-await-in-loop
+  const missing = Object.entries(fields)
+    .filter(([name, spec]) => !after || !after.fields[name] || after.fields[name].type !== spec.type)
+    .map(([name]) => name);
+  results.push({
+    table, created: !before, columns: Object.keys(fields).length,
+    verified: missing.length === 0, missing,
+    indexHint: INDEX_HINTS[table],
+  });
+}
+
+console.log(JSON.stringify({
+  ok: true,
+  server: cred.serverURL,
+  credentialSource: cred.source,
+  tables: results,
+}, null, 2));
+
+const failed = results.filter((r) => !r.verified);
+if (failed.length > 0) {
+  console.error(`[task-progress] ${failed.length} 表字段校验未通过: ${failed.map((f) => f.table).join(', ')}`);
+  process.exit(1);
+}

+ 185 - 0
skills/fmode-task-progress/scripts/parse-client.mjs

@@ -0,0 +1,185 @@
+/**
+ * parse-client.mjs — Parse REST 客户端 + 凭据四级解析(零硬编码)
+ *
+ * 上报后端:https://server.fmode.cn/parse/classes/<Table>
+ * 鉴权:X-Parse-Application-Id + X-Parse-Master-Key(或用户 sessionToken)
+ *
+ * 凭据解析链(优先级从高到低,全部运行时读取,零密钥入库):
+ *   1. 环境变量 PARSE_APP_ID / PARSE_MASTER_KEY(或 sessionToken 走 X-Parse-Session-Token)
+ *   2. ~/.fmode/config.json → parse 段({ appId, masterKey } 或顶层同名键)
+ *   3. future-server/config/config.nova.json → parse 段(平台开发机标准位置)
+ *   4. 都找不到 → 打印指引并退出(绝不猜、绝不硬编码)
+ *
+ * Date 字段铁律(schema 111 坑):
+ *   裸字符串 "2026-09-12T08:00:00.000Z" 直接写 Parse 报 schema 111;
+ *   必须 wrapDate() 包装为 {"__type":"Date","iso":"..."} —— 本模块统一处理。
+ */
+
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+const DEFAULT_SERVER_URL = process.env.PARSE_SERVER_URL || 'https://server.fmode.cn/parse';
+
+function readJsonMaybe(filePath) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return null;
+    return JSON.parse(fs.readFileSync(filePath, 'utf-8').replace(/^/, ''));
+  } catch {
+    return null;
+  }
+}
+
+/** 在任意层级找 parse 配置段:{parse:{appId,masterKey}} 或顶层 {appId,masterKey} */
+function extractParseSection(obj) {
+  if (!obj || typeof obj !== 'object') return null;
+  const sec = obj.parse && typeof obj.parse === 'object' ? obj.parse : obj;
+  if (sec.appId && sec.masterKey) {
+    return { appId: String(sec.appId), masterKey: String(sec.masterKey), serverURL: sec.serverURLOnline || sec.publicServerURL || sec.serverURL || null };
+  }
+  return null;
+}
+
+/** 凭据四级解析。返回 {appId, masterKey, serverURL, source} 或 null。 */
+export function resolveCredentials() {
+  // 第 1 级:环境变量
+  if (process.env.PARSE_APP_ID && process.env.PARSE_MASTER_KEY) {
+    return {
+      appId: process.env.PARSE_APP_ID.trim(),
+      masterKey: process.env.PARSE_MASTER_KEY.trim(),
+      serverURL: process.env.PARSE_SERVER_URL || DEFAULT_SERVER_URL,
+      source: 'env',
+    };
+  }
+  // 第 2 级:~/.fmode/config.json
+  const homeCfg = readJsonMaybe(path.join(os.homedir(), '.fmode', 'config.json'));
+  const fromHome = extractParseSection(homeCfg);
+  if (fromHome) {
+    return { ...fromHome, serverURL: fromHome.serverURL || DEFAULT_SERVER_URL, source: '~/.fmode/config.json' };
+  }
+  // 第 3 级:future-server config.nova.json(平台开发机标准位置)
+  const candidates = [
+    path.join(os.homedir(), 'git-repos', 'future-server', 'config', 'config.nova.json'),
+    '/opt/data/git-repos/future-server/config/config.nova.json',
+    process.env.FUTURE_SERVER_DIR ? path.join(process.env.FUTURE_SERVER_DIR, 'config', 'config.nova.json') : null,
+  ].filter(Boolean);
+  for (const p of candidates) {
+    const cfg = readJsonMaybe(p);
+    const sec = extractParseSection(cfg);
+    if (sec) {
+      return { ...sec, serverURL: sec.serverURL || DEFAULT_SERVER_URL, source: p };
+    }
+  }
+  return null;
+}
+
+/** 未配置凭据时的统一报错指引(退出码 2) */
+export function failNoCredentials() {
+  console.error('[task-progress] 未找到 Parse 凭据。按以下任一方式配置(零硬编码,凭据不入仓):');
+  console.error('  1. 环境变量: export PARSE_APP_ID=<appId> PARSE_MASTER_KEY=<masterKey>');
+  console.error('  2. ~/.fmode/config.json 写入 {"parse":{"appId":"...","masterKey":"..."}}');
+  console.error('  3. 平台开发机: future-server/config/config.nova.json 的 parse 段自动读取');
+  process.exit(2);
+}
+
+/** Date 字段包装:裸字符串 → {"__type":"Date","iso":...}(防 schema 111) */
+export function wrapDate(value) {
+  if (!value) return value;
+  if (value && typeof value === 'object' && value.__type === 'Date' && value.iso) return value;
+  const d = new Date(value);
+  if (isNaN(d.getTime())) throw new Error(`无效日期: ${value}`);
+  return { __type: 'Date', iso: d.toISOString() };
+}
+
+/** Pointer 包装 */
+export function wrapPointer(className, objectId) {
+  return { __type: 'Pointer', className, objectId };
+}
+
+function headers(cred, extra) {
+  const h = {
+    'X-Parse-Application-Id': cred.appId,
+    'X-Parse-Master-Key': cred.masterKey,
+    'Content-Type': 'application/json',
+    ...extra,
+  };
+  return h;
+}
+
+async function parseFetch(url, options) {
+  const res = await fetch(url, { ...options, signal: AbortSignal.timeout(30000) });
+  const body = await res.json().catch(() => null);
+  if (!res.ok) {
+    const code = body && body.code ? body.code : res.status;
+    const err = new Error(`Parse ${res.status} code=${code}: ${JSON.stringify(body && body.error ? body.error : body)}`);
+    err.parseCode = code;
+    throw err;
+  }
+  return body;
+}
+
+/** GET /classes/<Table>(filters: {where, limit, order, keys}) */
+export async function queryObjects(table, cred, filters = {}) {
+  const qs = new URLSearchParams();
+  if (filters.where) qs.set('where', JSON.stringify(filters.where));
+  if (filters.limit) qs.set('limit', String(filters.limit));
+  if (filters.order) qs.set('order', filters.order);
+  if (filters.keys) qs.set('keys', filters.keys);
+  const url = `${cred.serverURL}/classes/${table}${qs.toString() ? `?${qs}` : ''}`;
+  const body = await parseFetch(url, { method: 'GET', headers: headers(cred) });
+  return body.results || [];
+}
+
+/** POST /classes/<Table> 创建;data 中 Date 字段自动包装 */
+export async function createObject(table, cred, data) {
+  const url = `${cred.serverURL}/classes/${table}`;
+  return parseFetch(url, { method: 'POST', headers: headers(cred), body: JSON.stringify(data) });
+}
+
+/** PUT /classes/<Table>/<objectId> 更新;data 中 Date 字段自动包装 */
+export async function updateObject(table, cred, objectId, data) {
+  const url = `${cred.serverURL}/classes/${table}/${objectId}`;
+  return parseFetch(url, { method: 'PUT', headers: headers(cred), body: JSON.stringify(data) });
+}
+
+/** GET /schemas/<Table>(schema 校验用) */
+export async function getSchema(table, cred) {
+  const url = `${cred.serverURL}/schemas/${table}`;
+  try {
+    return await parseFetch(url, { method: 'GET', headers: headers(cred) });
+  } catch (e) {
+    if (e.parseCode === 103 || (e.message && e.message.includes('class'))) return null; // 表不存在
+    throw e;
+  }
+}
+
+/** POST /schemas/<Table> 建表;已存在的表(code 105)回落 PUT 增量补列(幂等) */
+export async function createSchema(table, cred, fields) {
+  const url = `${cred.serverURL}/schemas/${table}`;
+  try {
+    return await parseFetch(url, { method: 'POST', headers: headers(cred), body: JSON.stringify({ className: table, fields }) });
+  } catch (e) {
+    if (e.parseCode === 105 || (e.message && e.message.includes('already exist'))) {
+      // 表已存在:PUT /schemas/<Class> 增量加缺失列(已存在的列 Parse 自动忽略)
+      const body = await parseFetch(url, { method: 'PUT', headers: headers(cred), body: JSON.stringify({ fields }) });
+      return { idempotent: true, ...body };
+    }
+    // 106 = 字段已存在(幂等场景视为成功)
+    if (e.parseCode === 106 || (e.message && e.message.includes('already exist'))) {
+      return { idempotent: true, parseCode: e.parseCode };
+    }
+    throw e;
+  }
+}
+
+/** 幂等 upsert:按 where 查第一条,命中则覆盖更新,未命中则创建。返回 {objectId, created} */
+export async function upsertObject(table, cred, where, data) {
+  const existing = await queryObjects(table, cred, { where, limit: 1 });
+  if (existing.length > 0) {
+    const objectId = existing[0].objectId;
+    await updateObject(table, cred, objectId, data);
+    return { objectId, created: false };
+  }
+  const created = await createObject(table, cred, data);
+  return { objectId: created.objectId, created: true };
+}

+ 86 - 0
skills/fmode-task-progress/scripts/progress.mjs

@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+/**
+ * progress.mjs — 任务进度四态上报(AgentTaskStatus 表)
+ *
+ * 四态: ack → running → done | failed,running 期间心跳保鲜。
+ * 幂等: 同 agentId+taskId 覆盖更新(重复上报不产生脏数据)。
+ *
+ * 用法:
+ *   node progress.mjs ack     --agent <agentId> --task <taskId> [--name <任务名>] [--note <备注>]
+ *   node progress.mjs running --agent <agentId> --task <taskId> [--note <备注>]
+ *   node progress.mjs done    --agent <agentId> --task <taskId> [--note <结果备注>]
+ *   node progress.mjs failed  --agent <agentId> --task <taskId> --note <失败根因>
+ *   node progress.mjs heartbeat --agent <agentId> --task <taskId>
+ *
+ * 纪律: running 状态 30s 无心跳 = 疑似死亡,调度层应主动查,不靠"以为还在跑"。
+ */
+
+import {
+  resolveCredentials, failNoCredentials, wrapDate, upsertObject, queryObjects,
+} from './parse-client.mjs';
+
+const TABLE = 'AgentTaskStatus';
+const VALID = ['ack', 'running', 'done', 'failed'];
+
+function parseArgs(argv) {
+  const state = argv[0];
+  const args = { state };
+  for (let i = 1; i < argv.length; i += 2) {
+    const key = (argv[i] || '').replace(/^--/, '');
+    args[key] = argv[i + 1];
+  }
+  return args;
+}
+
+const args = parseArgs(process.argv.slice(2));
+const isHeartbeat = args.state === 'heartbeat';
+if (!VALID.includes(args.state) && !isHeartbeat) {
+  console.error(`用法: node progress.mjs <ack|running|done|failed|heartbeat> --agent <agentId> --task <taskId> [--name <任务名>] [--note <备注>]`);
+  process.exit(1);
+}
+if (!args.agent || !args.task) {
+  console.error('[task-progress] --agent 与 --task 必填(幂等键 agentId+taskId)');
+  process.exit(1);
+}
+
+const cred = resolveCredentials();
+if (!cred) failNoCredentials();
+
+const now = new Date();
+const where = { agentId: args.agent, taskId: args.task };
+
+// 心跳:只刷 heartbeatAt,不动状态字段
+if (isHeartbeat) {
+  const existing = await queryObjects(TABLE, cred, { where, limit: 1 });
+  if (existing.length === 0) {
+    console.error(`[task-progress] 心跳目标不存在(先 ack): ${args.agent}/${args.task}`);
+    process.exit(1);
+  }
+  const r = await upsertObject(TABLE, cred, where, { heartbeatAt: wrapDate(now) });
+  console.log(JSON.stringify({ ok: true, op: 'heartbeat', objectId: r.objectId, at: now.toISOString() }));
+  process.exit(0);
+}
+
+// 四态上报:字段组装
+const data = {
+  agentId: args.agent,
+  taskId: args.task,
+  status: args.state,
+  heartbeatAt: wrapDate(now),
+};
+if (args.name) data.taskName = args.name;
+if (args.note) data.resultNote = args.note;
+if (args.state === 'ack') data.startedAt = wrapDate(now);
+if (args.state === 'done' || args.state === 'failed') data.endedAt = wrapDate(now);
+
+const r = await upsertObject(TABLE, cred, where, data);
+console.log(JSON.stringify({
+  ok: true,
+  op: 'progress',
+  state: args.state,
+  agentId: args.agent,
+  taskId: args.task,
+  objectId: r.objectId,
+  created: r.created,
+  at: now.toISOString(),
+}));

+ 60 - 0
skills/fmode-task-progress/scripts/status.mjs

@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+/**
+ * status.mjs — 进度/成果查询(AgentTaskStatus + AgentDeliverable)
+ *
+ * 视角:
+ *   node status.mjs mine   --agent <agentId>          本 agent 最近任务+交付
+ *   node status.mjs all    [--agent <agentId>] [--limit N]   全集群(masterKey 视角)
+ *
+ * 输出 JSON:tasks(四态时间线倒序)+ deliverables(成果时间线倒序)。
+ */
+
+import {
+  resolveCredentials, failNoCredentials, queryObjects,
+} from './parse-client.mjs';
+
+const argv = process.argv.slice(2);
+const scope = argv[0] === 'all' ? 'all' : 'mine';
+// 选项从 argv[1] 起按对解析: --agent x --limit 20
+const args = {};
+for (let i = 1; i < argv.length; i += 2) {
+  const key = (argv[i] || '').replace(/^--/, '');
+  args[key] = argv[i + 1];
+}
+const limit = Math.min(parseInt(args.limit || '20', 10) || 20, 200);
+
+const cred = resolveCredentials();
+if (!cred) failNoCredentials();
+
+const taskWhere = args.agent ? { agentId: args.agent } : {};
+const delivWhere = args.agent ? { agentId: args.agent } : {};
+
+const [tasks, deliverables] = await Promise.all([
+  queryObjects('AgentTaskStatus', cred, { where: taskWhere, limit, order: '-updatedAt' }),
+  queryObjects('AgentDeliverable', cred, { where: delivWhere, limit, order: '-deliveredAt' }),
+]);
+
+const fmtTask = (t) => ({
+  agentId: t.agentId, taskId: t.taskId, taskName: t.taskName,
+  status: t.status,
+  startedAt: t.startedAt && t.startedAt.iso,
+  endedAt: t.endedAt && t.endedAt.iso,
+  heartbeatAt: t.heartbeatAt && t.heartbeatAt.iso,
+  resultNote: t.resultNote,
+});
+const fmtDeliv = (d) => ({
+  agentId: d.agentId, taskId: d.taskId, title: d.title,
+  project: d.project, deliverableType: d.deliverableType,
+  url: d.url, summary: d.summary,
+  deliveredAt: d.deliveredAt && d.deliveredAt.iso,
+});
+
+console.log(JSON.stringify({
+  ok: true,
+  scope,
+  agentId: args.agent || null,
+  taskCount: tasks.length,
+  deliverableCount: deliverables.length,
+  tasks: tasks.map(fmtTask),
+  deliverables: deliverables.map(fmtDeliv),
+}, null, 2));