---
name: sotogoal
description: 通过 SotoGoal（所及）REST API 读写任务/知识/目标。当用户要在 SotoGoal 里查找、创建、更新任务(task)、知识条目(note)或目标(goal)，按短号(#T12/#G7/#N3)定位实体，或读/写实体上的讨论(评论、纠偏、批注)时使用。旧名 FlowOS。
---

# SotoGoal · 所及

用一枚 API 令牌调 SotoGoal 的 REST，读写任务(task)、知识(note)、目标(goal)。

## 前置：凭证

需要一个环境变量（第二个可选）：

- `SOTOGOAL_TOKEN` — 用户在 SotoGoal「账户设置 → API 令牌」里创建的令牌，形如 `fos_live_…`（dev 环境签发的是 `fos_dev_…`）。
- `SOTOGOAL_BASE_URL`（可选）— SotoGoal 服务地址；**未设置时默认线上站点 `https://sotogoal.com`**。

产品旧名 FlowOS，旧变量名 `FLOWOS_TOKEN` / `FLOWOS_BASE_URL` / `FLOWOS_NO_UPDATE_CHECK` 仍然可用：新名优先，新名未设置或为空时回落到旧名（空串视同未设，同 shell 惯例）。

所有请求带 `Authorization: Bearer $SOTOGOAL_TOKEN`。若缺变量或收到 `401 invalid_token`，请让用户去账户设置新建/确认令牌，不要自行猜测。

随附的薄 CLI `sotogoal.mjs` 已封装好鉴权与常用操作，优先用它；需要自定义时再直接 curl。

## 你手上这份是不是旧的

本文档对应 skill bundle **0.2.9**，写作时服务端登记的行为口径集是 `[]`。本地安装的 skill 是一次性快照，站点改了它不会跟。

**`GET /api/me` 是真源**（CLI：`sotogoal.mjs whoami`），拿它的两个字段跟上面两个值比：

- `skillVersion` 不一致 → 你这份旧了。重装 `curl -fsSL https://sotogoal.com/skill/install.sh | sh -s -- <你的 skills 目录>`，并**重新读一遍** `SKILL.md`（拉下来直接读进上下文，本会话的口径才会跟上）。
- `semantics`（每项 `{id, summary}`）里有而你没有的 → 同样是你旧了；你有而它没有的 → 这个部署旧了或该口径已下线，用到它会**静默拿到错结果**。

`semantics` 只收*不知道它就会静默做错事*的行为口径；会明确报错的（端点不存在、参数非法）一律不收，所以它很短、为空也正常。**判断某个端点在不在，直接调用就行。** 它与令牌权限无关，那是同一响应里的 `scopes`。

同样两个值也搭在令牌鉴权的响应头上——`X-Skill-Version` 与 `X-Api-Semantics`（逗号分隔的 id），含义与用法一致，只是省掉一次请求。**头不在不影响任何功能**，问 `/api/me` 即可。

CLI 已内建这套自检，需要更新时会往 stderr 打印一行更新提示（可通过 `SOTOGOAL_NO_UPDATE_CHECK=1` 关闭，版本与口径两项一起关；同一条提示 24h 内只印一次，`whoami` 不受此限）。**直接 curl 的调用方请自己读这两个头，或定期问一次 `/api/me`。**

## 权限与空间（重要）

令牌是「受限的用户」：能做的事不超过令牌持有人本人的权限，且被令牌勾选的 scope 收窄。

- **拿不准就先 `GET /api/me`**（无需任何 scope）：返回本令牌的 `scopes`、**访问集** `spaces`（可读写的空间清单，每项 `{id, slug, name}`——用户口头说的空间名/slug 在这里解析成 id）、**默认空间** `defaultSpaceId`（`spaces` 里的一项；为 `null` 表示本令牌没有可用的默认空间，那时每个请求都得显式带空间）、`personalSpaceId`、`via`（令牌标识，见下条）。CLI：`sotogoal.mjs whoami`。

- **写入的身份分两个字段，别混**：**作者**（`author` 显示名 / `authorId` 用户 id）恒为**令牌持有人**——令牌以持有人身份写入，调用方指定的 `author` 一律丢弃；**`via`** 是另一个维度，标**经哪枚令牌**写入（持有人建令牌时可单独设一个，没设则是令牌名称）。网页端把 `via` 渲染成 `⚡ 经 <via>` 的 chip。评论带 `author` / `authorId` / `via`，正文历史带 `authorId` / `via`（没有 `author`）。

- **个人空间就是一个普通空间**，在 `spaces` 里恒排首位，**slug 固定为保留字 `me`**。实体上的 `spaceId` 一律是真实的空间 id（个人空间也不例外，与 `personalSpaceId` 相等），所以判断归属直接比 id 即可。

- **两个保留字，只认小写原字**（任何团队都占不到它们）：
  - `me` = 我自己，**四处同一个词**：寻址 `?space=me`、创建落点 `"spaceId": "me"`、负责人（`assigneeIds` 的元素，以及加/摘负责人时的 `userId`）、流转事件 `?actor=me`。
  - `all` = 全部可见空间：`?space=all`（只有列表与搜索收）。
  - ⚠️ **大写或混写不是保留字**：`ME` / `All` 会被当成空间 slug、空间 id 或 userId 去解析，解析不到即报错——`404 space_not_found`、`404 actor_not_found`、`400 invalid_assignees`。

- ⚠️ **省略空间参数从不落个人空间**：要回个人空间一律显式写 `me`。省略时各端点落在哪儿，见下面两条。
- **默认空间 = 访问集里唯一的那个空间，管三处**：`spaces` 只有一项时，**创建落点**（`POST` 省略 `spaceId`）、**`GET /api/tasks|notes|goals` 列表**（省略 `?space=`）、**报表/流转事件**这三处都落它。`spaces` **多于一项时没有默认空间**（`defaultSpaceId` 为 `null`），这三处一律要点名空间，省略返 `400 space_required`。要跨空间列表写 `?space=all`。
- ⚠️ **另外两处与访问集大小无关**：三个 `/search` 缺省搜遍全部可见空间；**短号寻址**省略 `?space=` 时在**访问集**内解析（见「短号寻址」）。一句话记：**列表落一个空间，搜索与短号寻址覆盖全部够得着的空间**。搜索同样收 `?space=`（`all` 为 no-op）。
- scope 按 `资源:动作` 授予：`tasks:read` / `tasks:write` / `tasks:delete`（notes/goals 同理），或 `*` 全部。**写不含删**——删除需单独授予。这里的「删」指**销毁实体**（软删整条任务/知识/目标）；删实体的一个**属性**（撤一条依赖、删一条 KR）算写，只需 `<资源>:write`。
- **回收站的两档分开**（见「回收站」一节）：**枚举** `GET /api/trash` 要 `<资源>:delete`；**按 id 恢复一条**只要 `<资源>:write`。`?type=` 点名的类型缺那一档返 `403 insufficient_scope`（`need` 指名）；**没点名时只回够得着的那几类**，包含了哪几类由信封的 `types` 如实报出；三类都够不着返 `403 insufficient_scope`。
- **周期没有自己的一轴，它随任务权限**：读周期与周期设置要 `tasks:read`，改周期设置要 `tasks:write`（另需空间管理员，见「周期」一节）。把任务拉进周期、撤回承诺、顺延同样只要 `tasks:write`——改的都是**任务**。
- **评论是独立的一轴**：`comments:read` / `comments:write` / `comments:delete`（同样写不含删）。它与宿主资源**叠加生效**——操作某个实体的评论，除 `comments:<动作>` 外还要有该实体的 **read 档**（读任务的讨论 = `comments:read` + `tasks:read`），缺哪档就报哪档（`403 insufficient_scope` 的 `need` 会指名）。⚠️ 这一轴是后加的：**在此之前建的令牌不带 `comments:*`**（除非勾的是 `*`），令牌的 scope 又不支持修改，只能请用户**重建一枚**。
- 令牌的空间授权只有**访问集**一层（能触达哪些空间——安全边界，集外空间 `403 space_forbidden` 或查不到）；默认空间由访问集推导，不是单独设的一项（见上）。
- **空间参数的四个取值**：`<slug>`、`<空间 id>`、`me`（个人空间）、`all`（全部可见空间，**只有列表与搜索收**）。**寻址**端点给 `?space=all` 返 `400 invalid_space`——寻址要落到一个具体空间（uuid 寻址不用它定位实体，但值同样校验，见「`?space=` 管什么」）。
- 只用本文档列出的端点；其余端点（如 `/api/data`、`/api/sync`、`/api/stream`）对令牌一律返回 `403 session_only`。
- 空间的命名约定：**查询参数**叫 `space`（寻址用，收 slug 或 id）；**实体字段**叫 `spaceId`（id）。两处都额外接受保留字 `me`。
- ⚠️ **`spaceId` 的值域是「空间 id + 保留字 `me`」，空串与空间 slug 都不在其中**：`"spaceId": ""` 与 `"spaceId": "<slug>"` 都返 `400 invalid_space_id`，不会落到任何空间（查询参数的 `?space=` 才收 slug，别把两者混用）。拼 body 时若某个空间 id 取不到值，**别拿空串或 slug 顶上**——个人空间写 `me`，团队空间写 `/api/me` 的 `spaces[].id`；整个省略这个字段才是「落默认空间」。
- ⚠️ **`spaceId` 只在创建时可用**，它决定实体落在哪个空间：`PATCH` 带它（含 `"spaceId": null`）返 `400 create_only_field`。已建实体换空间不在令牌的能力范围内，请用户在网页端处理。

## 短号寻址

SotoGoal 每个实体有对人友好的短号：目标 `#G7`、任务 `#T203`、知识 `#N3`。

短号**按空间独立编号**，两个空间可以都有 `#T1` —— 它只有配上空间才是一个完整地址。`id`（uuid/cuid）则全局唯一，寻址不需要任何上下文。

- **发请求优先用 `id`**：搜索/列表结果每条都带 `id`，从结果回来定位实体一律用它。跨空间的结果集（`/search` 缺省、`list` 带 `?space=all`）里短号会重号，拿去寻址会落到另一个实体上。
- **短号只在这三种情况用**：
  1. **跟用户沟通**——"已创建 #T204"、"看下 #T413"，对人一律用短号；
  2. **手上只有一个短号**——直接发，省略 `?space=`：服务端在**令牌访问集**内解析，唯一命中即返回；多个空间有同号返 `400 ambiguous_seq`，附候选（每条带 `shortId` / `spaceId` / `title`）；
  3. **手上是「空间 + 短号」**——写**限定短号** `<空间>:<短号>`（下一条），或带 `?space=<slug | id | me>`，两者都一次到位，不会撞上第 2 条的歧义。

- **撞号了怎么定下来是哪一个**——三档，命中即停：
  - **拿用户话里的内容对候选标题**。用户常连内容一起说（"修复 #T415 弹窗错误"），对得上就直接用，不必打扰他。
  - 对不上就按内容语义搜索，或拉更多字段再判：候选只带上面三个字段，要 `tags` / `kind` 等改用 `?space=all&seq=413`（见「按短号查找」）。
  - 仍定不下来，**把候选摆成菜单式选项让用户挑一个**，不要用一句散文去问。每条显示「空间名 · 短号 · 标题」；**空间名由 `GET /api/me` 的 `spaces` 把 `spaceId` 映射出来——uuid 不摆给用户**。没有菜单能力的客户端出编号列表。响应带 `candidatesTotal` 时它大于候选条数，菜单要交代还有更多，别让用户以为这就是全集。
- **限定短号 `<空间>:<短号>`**：`gewu:T203`、`me:T12`——把空间写进号里，于是它自成一个完整地址。空间段收 slug、空间 id 或 `me`（与 `?space=` 同一套词，逐字小写；`all` 不适用于寻址，返 `400 invalid_space`）。凡收短号的地方都收它：URL 的 `:id`、评论的 `entityId`。空间段解析不到返 `404 space_not_found`，令牌够不着返 `403 space_forbidden`。同时给了 `?space=` 时以路径里的空间段为准。对话与正文里带 `#` 写作 `#gewu:T203`。
- **URL 里的 `:id` 同时接受 uuid 和短号**：单实体 GET、PATCH/DELETE、生命周期动词、KR 端点都认，如 `POST /api/tasks/T203/close?space=flow`。写法是裸数字 `203`、带类型字母 `T203`（大小写均可，**推荐**——字母与端点类型不符会 404，能拦住「把 `G7` 误发到 tasks 端点」这类错），或限定短号 `flow:T203`。`#` 只是对话/正文里的显示写法，别写进 URL（它起 fragment，客户端根本不会把它及其后内容发给服务端）；CLI 参数同理（shell 里 `#` 还会起注释）。
- **body 里的引用字段只认 uuid**（`goalId`、`duplicateOfId`、`krId` 等）——裸数字不会被当短号，需要引用时先 get/search 拿 `id`。
- **正文里引用实体也用短号**：在 `bodyMarkdown`/`bodyText` 里直接写 `#T12`/`#G7`/`#N3`（须带 `#`、大写类型字母），落库时会解析成可点击的引用 chip（图标 + 实时标题，点击跳转），并自动在两个实体间建立「关联」（出现在双方详情页的关联面板）。**评论正文里同样会解析成 chip，但只出 chip、不建关联**（讨论里顺口提一句不该改动实体间的关联图）。关联随正文全量同步：重写正文时不再引用的短号，其关联会被一并回收（用户手动钉住的不受影响）。解析限定在写入实体所在的空间；解析不到（不存在/跨空间/已删除）就保留为纯文本，不报错。代码块和链接 URL 里的短号不会被转换。

### `?space=` 管什么：看它配的是寻址还是过滤

`?space=` **不是一个统一的过滤参数**——同一个参数在三处角色不同：

| 用在哪 | `?space=` 的角色 | 省略它 |
| --- | --- | --- |
| **短号寻址**（`/api/tasks/T203`） | **地址的一部分**。短号按空间独立编号，不配空间不成其为地址 | 在访问集内解析：唯一命中即用，多个空间有同号返 `400 ambiguous_seq`。改写限定短号 `/api/tasks/flow:T203` 可省掉这个参数 |
| **uuid 寻址**（`/api/tasks/<uuid>`） | **不用来定位实体**（uuid 全局唯一），但值本身照校验：解析不到、给 `all` 都报错 | 无差别 |
| **列表**（`GET /api/tasks\|notes\|goals`） | **本次生效的范围**。决定回哪些行，也决定 `?seq=` / `?goal=` / `?tags=` 的值在哪里解析 | 落默认空间；没有默认空间时返 `400 space_required`（要全部得显式 `?space=all`） |

所以同一个 uuid 有两种命运：`GET /api/goals/<uuid>` 不带 `?space=` 也拿得到，而 `?goal=<同一个 uuid>` 不带 `?space=` 会落空——**前者是寻址，后者是过滤**。

一句话：**寻址问「它是谁」，过滤问「这个范围里哪些符合」。** uuid 自己就是完整答案，寻址不需要范围；过滤永远发生在某个范围之内，参数值越不出它。

## 查询参数的总口径

**不认识、不适用、不合法一律报错，没有静默忽略这回事。**

- 端点不受理的参数返 `400 unknown_param`，响应附 `validParams` = 本端点的合法参数集。拼错一个字母（`?spce=`）不会被当作没给。
- 参数在**别的**端点上受理时（把 `?goal=` 发给 `/api/notes`），响应另附 `paramEndpoints` 指出受理它的端点——那多半意味着请求发错了端点。
- 受理的参数**值**解析不到照样报错（逐个见「错误码速查」），不会降级成默认值、缺省序或全集。
- **空值（`?x=`）视同没给**；`?seq=` / `?goal=` / `?tags=` 是例外，见「错误码速查」。

## body 字段的总口径

同一条规则，换到写请求的 body 上。

- 端点不受理的字段返 `400 unknown_field`，响应附 `validFields` = 本端点的合法字段集。写错名字、或照另一类实体的字段名去写（`prio` 发给目标端点），都不会被当作没给。
- 该字段在本实体上**有对应写法**时，`hint` 直接指名（`prio` → `priority`、`dueAt` → `deadline`、`goalId` → `parentId`）；本实体没有那条轴时 `hint` 直说没有。
- 有些字段另有专门的错误码（`tags` → `use_tags_endpoint`、知识的 `goalId`/`taskId`/`links` → `use_body_short_ref`），报的是那个码，指路更具体。
- 字段名对、但**这个动词**不受理它（只在创建时可用的落点/初始化字段）返 `400 create_only_field`，`field` 指名是哪个。
- 字段名对但**值**不合法（枚举写错、日期不是完整 ISO）返 `400 invalid_input`，`details` 里列出是哪个字段的哪个值。

## 分页的总口径

**凡是「有界集合的一个窗口」，一律这一套。** 今天是三类实体的 list（`GET /api/tasks|notes|goals`）与评论（`GET /api/comments`）。

- `?limit=` 决定这一页几条：list 缺省 100、评论缺省 200，**上限都是 500**，超出即夹到上限。
- 响应恒带 `hasMore`（还有没有下一页）与 `nextCursor`（取下一页的游标），两者同进同出：`hasMore: false` 时 `nextCursor` 为 `null`。
- 取下一页：把 `nextCursor` **原值**放进 `?cursor=`，其余参数一字不改。游标**不透明**，别解开读、别自己拼——内容与形状随时会变。
- 本次的排序（`?order=`）或任何过滤参数与取到这个游标的那次请求不一致，返 `400 invalid_cursor`：换条件就去掉 `?cursor=` 从第一页重新翻。
- `?since=`（增量同步）**不分页**，整段回。它与 `?limit=`/`?cursor=` 同时给返 `400 pagination_unsupported_with_since`。

⚠️ **`?cursor=` 是「接着上一次继续」，不是「往后翻」。** 往哪个方向继续由该端点自己的排序定：三类 list 的第一页是最近更新的那批，继续下去是更早更新的；**评论的第一页是最近 N 条，所以继续下去是更旧的那批**。

⚠️ **`hasMore: true` 就是「你还没看完」。** 计数、盘点、「有没有这条」这类结论必须翻到 `hasMore: false` 才成立——一页数据里每一行都是对的，少掉的那些不会有任何迹象。

⚠️ **翻页不是一致性快照**：分页锚在会变的字段上，所以翻页期间被改动的行**可能这一趟就漏掉**（已经翻过的行被改动不会重复出现）。拿一批出来干活没问题；**对账不行**——真要对账先用 `?updatedAfter=` / `?doneAfter=` 之类把范围钉死。

**三类明确不在这套口径里**，它们没有「下一页」：

- `GET /api/*/search` 与 `/:id/recall`——返回的是**相关度最高的前 N 条**，不是「第一页」。要更多结果就换措辞重搜，或调 `?limit=`。
- **正文历史列表**——超出条数上限后抽掉的是**中间**的版本（两端保留），那是取样不是窗口，见「正文历史」。
- **详情信封里的 `linksTruncated`**——单个方向的边超过上限时它为 `true`，截掉的是**更旧**的边，而且**没有续取的办法**：拿不回来。见「关联」。

## 越界报 403 还是 404：看越界的是参数还是实体

- **`?space=` 指向令牌访问集外的空间** → `403 space_forbidden`。参数里的空间名拿 `GET /api/me` 的 `spaces` 一比就知道够不够得着。
- **被寻址的实体在访问集外** → `404`（单实体端点 `not_found`，评论宿主 `entity_not_found`）。实体存在与否不对够不着它的调用方披露，所以拿不到「它存在但你够不着」这种回答。

唯一的例外是 `DELETE /api/comments/:id`：它按评论 id 直接定位，宿主越界在那里报 `403 space_forbidden`。

## 常用端点

任务 `task`（知识 `note`、目标 `goal` 的**端点**同构，把 `tasks` 换成 `notes`/`goals`；正文三类统一叫 `body*`）。**同构只到端点为止，字段三类各不相同**——目标的字段与守卫见「创建目标」，别照任务推。

- `GET  /api/tasks` — 列出可见任务（受 scope + 空间收窄）。**瘦投影，不含正文**（`hasBody` 标出哪些条目有正文），取正文走单实体 GET。
  - **空间**：省略 `?space=` 只回**令牌默认空间**（没有默认空间时返 `400 space_required`）；其余写 `?space=<slug | id | me>`，全部可见空间写 `?space=all`。响应信封恒带 **`space`** = 本次实际生效的空间 id（并集时为 `"all"`），**条数不对先看它**。解析不到返 `404 space_not_found`，越权返 `403 space_forbidden`。
  - **分页**：`?limit=` / `?cursor=`，响应带 `hasMore` / `nextCursor`——**默认只回第一页**，口径见「分页的总口径」。
  - **按短号查找**：`?seq=`，收 `413` / `T413` / `#T413`，逗号分隔可一次查多个。`?space=all&seq=413` 是「这个短号属于哪个空间」的标准解法。解析不出返 `400 invalid_seq`。
  - **按目标筛**（仅 task）：`?goal=`，收 `25` / `G25` / 目标 uuid，逗号分隔可一次给多个（取并集）。**只回直接挂在这些目标下的任务，不递归子目标**——要整棵子树，先 `GET /api/goals` 按 `parentId` 走出目标链，再把整串目标一起给。在本次生效的空间范围内解析（见「`?space=` 管什么」），且**给出的目标必须全部存在**：任一个解析不到返 `404 goal_not_found`，`?space=all` 下撞同号目标返 `400 ambiguous_goal`，读不出返 `400 invalid_goal`。
  - **按标签筛**（task 与 note）：`?tags=`，收标签名或标签 id，逗号分隔可一次给多个——**多值是 AND，只回同时挂着全部标签的条目**（要并集就一个标签发一次请求再自己合）。名字比对大小写与空白不敏感；在本次生效的空间范围内解析（见「`?space=` 管什么」）：`?space=all` 下同名标签取并集（不报歧义）。**给出的标签必须全部存在**：任一个解析不到返 `404 tag_not_found`，空值或纯空白返 `400 invalid_tags`；标签存在但没有条目同时挂着，返回的是 200 空列表。
  - **排序**：`?order=`，缺省 `updatedAt` 倒序——那是「最近发生了什么」，不是优先级。问「接下来做什么」用 `?order=gravity`，见「排序」一节。
  - **归档**：默认排除归档目标下的任务，`?archived=1` 只回它们——**list 里找不到 ≠ 不存在**，全量盘点/批量改要两趟都拉（跨空间时两趟都带 `?space=all`）。**唯一例外**：`?seq=` 在场时不排除归档。取值：`1` / `true` 只回归档，`0` / `false` 同省略，大小写不敏感。
  - **时间窗**（日报/周报聚合用）：`?doneAfter=&doneBefore=`（完成窗口）、`?createdAfter=&createdBefore=`、`?updatedAfter=&updatedBefore=`，值为 ISO 或 epoch ms，After 含 / Before 不含；可与 `?archived=` 组合。`doneAfter` 只命中 `resolution=completed`（dropped/duplicate 的关闭时刻查「阶梯流转事件」）。
- `GET  /api/tasks/:id` — 单个任务；`:id` 可为 uuid 或短号（见「短号寻址」）。响应里给令牌额外带 **`comments`**（一页讨论）、**`hasMore`** 与 **`commentCount`**（该实体存活评论的真实总数）——**`hasMore` 为 `true`、或 `comments` 键根本不在，才要另发一次请求去读讨论**，正文之外那层纠偏都在那儿，见下面「评论」一节。另外恒带 `links` / `linksTruncated` / `blocked`（无边时是 `[]` / `false` / `false`），见下面「关联」一节；所属目标见「归属目标」一节。
- `POST /api/tasks` — 创建。body 至少 `{ "title": "…" }`；可带 `goalId`、`spaceId`（空间 id，从 `/api/me` 的 `spaces` 里取，个人空间可写 `"me"`；**省略时落令牌的默认空间**，没有默认空间时返 `400 space_required`）、`prio`、`kind`/`domain`/`energy`/`dueAt`（分类、工作量与截止，见下面「分类、工作量与时间字段」——**别省，省了就是一批没分类、没期限的任务**）、`bodyText`、`links`（关联边，**仅创建时**，见「关联」）等。新任务即为待处理（`not_started`）；要开始/推进/关闭用下面的生命周期动词。
- `PATCH /api/tasks/:id` — 更新普通字段（`title`/`prio`/`kind`/`domain`/`energy`/`estimateDays`/`dueAt`/`bodyText`/`goalId` 等）。**任务的状态与阶段不经 PATCH**，改用下面的生命周期动词；**关联也不经 PATCH**（`400 use_links_endpoint`）；**标签同样不经 PATCH**（`400 use_tags_endpoint`，见「标签」一节）。
- `DELETE /api/tasks/:id` — 软删（需 `tasks:delete`）。**保留期内可恢复**，见「回收站」一节。

### 分类、工作量与时间字段（`kind` / `domain` / `energy` / `estimateDays` / `dueAt` / `startAt`）

这几个字段创建和 PATCH 都能带，**但服务端不会替你填**：省略 `kind`/`domain` 就落 `null`（空间设置里的「默认类型/领域」只作用于网页端的新建弹窗，走 API 不生效），省略 `energy` 落库默认 `mid`。于是**令牌建的任务默认是「无类型、无领域、中等工作量、无截止」**——看板筛不出来、引力排序也按一个没人给过的中位数在排。

**规则：用户说了的，就写上；用户没说但你能判断的（类型/领域/工作量），也写上。** 「下周五之前交」「这是个 bug」「大概两天的活」——这些话里的字段不落库，就只活在这轮对话里，等于没记。

- **`kind`（类型）**：`feature`(功能) | `bug`(缺陷修复) | `refactor`(重构) | `chore`(杂务) | `spike`(调研) | `doc`(文档)。可为 `null`。这一轴回答的是「这是哪一类**活动**」，不是「动到系统的哪一**层**」（那是 `domain` 的活）——所以**没有「架构」这一档**：一个架构改动仍然是 `feature` / `chore` / `spike`，配 `domain: tech`；`refactor` 专指「以改系统自身结构为目的」的活动。
- **`domain`（领域）**：`product`(产品) | `tech`(技术) | `design`(设计) | `market`(市场) | `ops`(运维)。可为 `null`。
- **`energy`（所需精力）**：`high` | `mid` | `low`，**恒有值**，缺省 `mid`。**没给 `estimateDays` 时它就是工作量估算**，影响引力排序与资源负载视图里的排期长度。给错了任务会排错位置。
- **`estimateDays`（估算工时·人日，float，可 `null`）**：给了就**覆盖** `energy` 的折算。心里有量级就直接给它，比让 `energy` 兜底准。
- **`dueAt`（截止，ISO 或 `null`）**：**用户一提到期限就设上**。逾期与临近截止都是引力排序的加分项，没有 `dueAt` 的任务在「接下来做什么」里永远不会因为要到期而浮上来。
- **`startAt`（计划开始日，ISO 或 `null`）**：资源负载视图的排期起点；不给则由「截止日 − 工时」倒推。

⚠️ **两个日期字段只收完整 ISO 时刻，不收「光日期」**：`"2026-09-15"` 和 `"2026-09-15T18:00:00"`（无时区）都返 `400 invalid_input`，要 `"2026-09-15T15:59:00.000Z"` 这种带时区的完整形式。

⚠️ **日期要按用户当地时区折算，别图省事写 UTC 零点**。网页端的日期选择器是「选一天 → 补一个当地时刻」：**截止补当地 23:59，开始日补当地 08:00**，然后转 ISO。请照同一口径写，否则同一个「9 月 15 日」在别的时区会显示成 14 号或 16 号，逾期判断也跟着错一天。（用户在 UTC+8 说「9 月 15 日截止」→ `2026-09-15T15:59:00.000Z`。）

⚠️ **`kind`/`domain`/`energy`/`prio` 都是真枚举**，写错返 `400 invalid_input`（`details` 里会列出合法取值）。请严格从上面几个集合里挑，拿不准就省略（`null` 好过错值）。注意值是**代号不是中文标签**：写 `refactor` 不是「重构」、写 `ops` 不是「运维」。

**正文读写（三类实体一致）**

*读*：单实体 GET 返回同一份正文的三种表示——`bodyText`（拍平的纯文本，快速扫一眼用）、`bodyHtml`（渲染产物）、以及 **`bodyMarkdown`（可以改完写回去的那一份）**。列表端点一律不含正文（`hasBody` 标出哪些值得取详情）。

*写*：只有 `bodyMarkdown`（结构化，优先）与 `bodyText`（一两句纯文本）两条通道，语义都是**替换整个正文**；`""` 即清空。直发 `bodyHtml`/`bodyJson`（知识的 `contentHtml`/`contentJson` 同理）会返 `400 use_body_markdown`——那是渲染产物与编辑器内部格式，直接入库会让三份正文失同步。

*支持的 markdown*：`#`~`######` 标题、有序/无序/嵌套列表（`3.` 开头会保留起始编号）、`- [ ]` 待办勾选、管道表格、代码块、` ```mermaid ` 图、引用（里面可以套列表/代码块）、粗/斜/删除线/`<u>` 下划线、行内代码、链接、行尾 `\` 硬换行。超出这个子集的语法降级为文本。

**「读 → 改 → 写」现在可行，但要看一面旗**。响应里跟 `bodyMarkdown` 一起来的还有：

- **`bodyMarkdownLossy: false`** —— 这份 md 写回去，文档还是原样。放心改。
- **`bodyMarkdownLossy: true`** —— 有 markdown 表达不了的东西（`bodyMarkdownLossyNodes` 说是什么：合并单元格、无表头行的表格、格内块级、作者手打的字面 `**星号**` 等）。`bodyMarkdown` **照样给你**（读得懂仍有价值），但整篇覆盖会丢掉那部分，所以直接 PATCH 会返 `400 lossy_overwrite_needs_force`；确认要覆盖再带 `"force": true`。**`nesting:capped` 是例外**：这一档带 `force` 也不放行，整篇覆盖返 `400 invalid_input`——出路是减少嵌套层数后重发，或不动正文、改发评论；层数上限与哪些标记计入层数见该响应的 `hint`。

列宽被重置属「可接受」，不翻旗。

**回写必须带 `baseVersion`**（取自同一次 GET 的 `version`）。转换器再无损也挡不住覆盖别人 30 秒前的编辑——撞车会返 `409 conflict` 并附 `current`（里面同样带 `bodyMarkdown`，可以基于它重算后重试）。

**chip 在 markdown 里长这样**（`ref://` 链接即 chip）：网盘文件 `[季度复盘.xlsx](ref://file/<id>?mime=…&size=…)`、@人提醒 `[@张三](ref://user/<id>)`、实体引用读侧 `[标题](ref://task|goal|note/<uuid>)`。网盘文件 chip 有两种形态，靠 `!` 前缀区分：`![名字](ref://file/<id>?…)` **独占一行**是块级预览卡，其余（没有 `!`，或同一行还有别的文字）是行内 chip——改写时抹掉 `!` 或把它并进一行文字，预览卡就**静默**降级成行内 chip。**读回来什么样就原样写回去**——它们会还原成 chip。尤其 @人 chip：把它降级成纯文本会让被 @ 的人**静默失去提醒**。主动新加 chip 做不到（没有成员/文件目录端点），**外部图片也插不了**（`![alt](https://…)` 会降级成普通链接）；要引用本站实体，写侧首选短号 `#T12`。

其他：note body 里的 `goalId`/`taskId`/`links` 返 `400 use_body_short_ref`——知识与目标/任务的关联在正文里用短号引用建立。创建示例 body：`{ "title": "…", "bodyMarkdown": "## 结论\n\n- 详见 #N3", "tagNames": ["…"] }`。

### 负责人（`assigneeIds`）

任务与目标共用这一轴：一组该空间成员的 **userId**。

**加/摘某一个人用颗粒化端点**（仅 task）——一次调用，不必先读整份再回写：

- `POST /api/tasks/:id/assignees` body `{"userId": "me"}` — 加一个。**幂等**：新加成功返 `201`，已经在里面返 `200` 且什么都不动（不是错）。成员校验只发生在**真要加进去**的时候，且只看你这次给的这个 userId——所以既有负责人里有人离开了空间，既不挡你加新人，也不会让你重复加他时莫名报错。
- `DELETE /api/tasks/:id/assignees/:userId` — 摘一个。**只需 `tasks:write`**（摘的是任务的属性，不是任务本身）。本来没指派过返 `404 assignee_not_found`。摘人**不校验成员身份**：已经离开这个空间的人照样摘得掉。
- 两条都**不收 `baseVersion`**：颗粒化增删幂等且可交换，不会撞 `409`。
- 目标没有这两个端点，改目标的负责人用下面的整份替换。

**整份替换**：`create` 与 `PATCH` 的 `assigneeIds`，或 `PUT /api/tasks/:id/assignees` body `{assigneeIds}`（目标同构）。它把这一轴**整组覆盖**，所以只在「这组就该是这几个人」时用；要加一个人用上面那条，否则会盖掉别人同时加的人（带 `baseVersion` 的 `PATCH` 会在撞车时返 `409`，`PUT` 则是后写覆盖）。

- **只有协作空间有这一轴**：个人空间不支持负责人，加人与写入非空值都返 `400 personal_space_no_assignees`；反方向（清空、摘掉某一个）不受限——存量指派在网页端看不见也点不掉，只能从这里清。
- **「我自己」写保留字 `me`**（口径见「权限与空间」）。别人的 userId 取自实体上已有的 `assigneeIds`，或流转事件的 `actorId`。
- `[]` 是**清空负责人**，不是「不改」——不想动这一轴就整个省略这个字段。
- 一个任务/目标最多 **100 个**负责人（`400 too_many_assignees`）——整份替换与颗粒化加人是同一个上限。
- **推进阶段时服务端会替你填**：用令牌 `move` 一条**此刻没有负责人**的协作空间任务，负责人落成令牌持有人。已经有人负责就原封不动——`move` 永远不改写已有的负责人。网页端的 `move`、以及个人空间的任务不触发这条。

### 正文历史（三类实体通用，**令牌只读**）

每一版装的是**改动前**的正文，所以「退回上一版」= 取最新那一版写回去。

- `GET /api/tasks|notes|goals/:id/revisions`（需该实体的 read 档）— 历史列表，**新到旧**。每条 `{id, at, authorId, via}`（超出可回溯天数的另带 `locked: true`，见下）。**不含正文，也没有任何正文派生量**（摘要、字数、改动行数都没有）。信封另带 `windowDays`。
- `GET /api/revisions/:id`（`:id` 取自上面列表的 `id`；需宿主实体的 read 档）— 那一版的正文，包在 `{revision: {…}}` 里：标识与身份（`id`、`entityType`、`entityId`、`at`、`authorId`、`via`）加三种正文表示与 lossy 旗，词汇与读法同「正文读写」一节。
- `authorId` = 那次改动的作者（经令牌写入时即令牌持有人）；`via` = 经由哪枚令牌（不经令牌的改动为 `null`）。

**连续改动会合并**：**作者与令牌都相同**、且距上一版不到 10 分钟的改动**不新开一版**——留下的仍是那一串改动里最早一次改动前的正文。⚠️ Agent 的「读—改—写」常在几十秒内做完，**别假定自己每写一次就留下了一版**。（网页端的改动不经令牌，与任何令牌写入都算不同手，所以人刚改完你再改，不会把人那一版并掉。）

**能回溯多久**：`windowDays` = 这个空间能回溯的天数，随订阅档位变；`null` 表示不设天数限制。超出它的条目**仍出现在列表里**并带 `locked: true`，取它的正文返 `403 revision_out_of_window`。**列表里最新的那一条不受这个限制**，所以「退回上一版」在任何档位都做得到。

**列表是历史的取样，不是完整的编辑流水**：条数有上限，超出后抽掉中间的版本（最新与最旧两端保留）。

**恢复不另开端点**：把取回的 `bodyMarkdown` 经 `PATCH` 写回该实体即可（回写的规矩同「正文读写」——带 `baseVersion`，lossy 时带 `force`）。

**删除历史不对令牌开放**（`403 session_only`），请用户在网页端处理。

### 回收站（三类实体通用）

`DELETE` 是软删：条目进回收站，**保留期内可原样恢复**；过了保留期就不再列出、也恢复不了，内容随后被彻底清除。**评论不进回收站**，删掉它没有恢复入口。

**列**：`GET /api/trash[?space=][&type=][&limit=][&cursor=]` — 还在保留期内的软删条目，三类**合并成一个列表**，按删除时间倒序。

- **空间**：`?space=` 的角色同三个 list（省略落令牌默认空间，跨空间写 `?space=all`），信封的 `space` 同样标明本次实际生效的空间。
- **分页**：`?limit=` / `?cursor=`，口径见「分页的总口径」。
- **按类型筛**：`?type=task,note,goal`，逗号分隔可给多个，省略 = 全部。读不出返 `400 invalid_type`（附 `validValues`）。
- 每条 `{type, id, shortId, spaceId, title, deletedAt, deletedBy, canRestore}`，**不含正文**。`spaceId` 是这条实体所在空间的 id（跨空间列表里短号会重号，用它区分）。`deletedBy` 是删它的那个人的 userId（`null` = 没有这个记录）。`canRestore` = 令牌**持有人本人**够不够格恢复这一条（判据见下）。
- 信封带 **`retentionMs`** = 保留期长度（毫秒），起点是每条自己的 `deletedAt`。**剩余时间由这两个值算，别把天数写死进你的逻辑。**
- 信封带 **`types`** = 本次**真的**包含了哪几类（令牌窄化的结果，见「权限与空间」的回收站 scope 一条）。⚠️ `types` 里没有的类型这次**一条都没查**，**不能读成「回收站里没有」**。

**恢复**：`POST /api/tasks|notes|goals/:id/restore`（`:id` 同款寻址，uuid 或短号）。返回 `{task}` / `{note}` / `{goal, restored}`，词汇同该类实体的写响应。

- **一次一条，没有批量**：每条各自过配额与权限。
- 标签、依赖与附件边随**同一次删除**带走的那一批一起回来，正文一直都在。
- **恢复目标 = 连同那次删除带走的整棵子树**（`restored` 列出真的恢复了哪几个目标的 `id`）；更早单独删掉的子目标不被顺带复活。⚠️ **挂在下面的任务不会回挂**——删目标时它们的 `goalId` 已被清空，恢复不还原这一项，需要就自己再写一次 `goalId`。父目标已不在、或仍在回收站里时，被恢复的目标成为**顶层**（这一步不受「在团队空间建顶层目标要管理员」那道门限制）。
- **本来就活着 → `200` 原样返回**（幂等，不是错）。
- 恢复任务会增加未完成任务数，可能撞 `403 task_limit_reached`。
- **谁恢复得了**：与删它**同一条**判据——该空间的管理员，或这条实体的创建者；不够格返 `403 forbidden`。列表里的 `canRestore` 就是这道判据的答案，不必自己推。

### 评论：正文之外的那一层（三类实体通用）

SotoGoal 里每个 task/note/goal 都能挂评论。纠正与改主意常常发在这里——"这条作废"、"改走方案 B 了"、"口径换成按周算"。所以正文是**创建当时**为真的东西，讨论里可能有更新的口径。

**动手之前先看 `comments`。** 单实体 GET（`/api/tasks|notes|goals/:id`）对令牌返回它，同级还有 `commentCount`（存活评论总数）与 `hasMore`。

- `comments` **不在信封里** —— 这枚令牌读不了讨论（缺 `comments:read`）。`commentCount` 照给：由它判断自己漏了什么。
- `comments` 在 —— 它是**一页**讨论（时间正序，条目形状同下面「读」）。`hasMore: false` 就是全部；`hasMore: true` 说明这一页之外还有**更旧**的，**去把评论读了**再动作。

不读也不会有任何别的迹象提醒你漏了一层，你只会拿着过期正文照做。

内联只给这一页，**不给 `nextCursor`**——要续取就走下面的 `GET /api/comments`，那才是评论的列表端点。

**读**：`GET /api/comments?entityType=task|note|goal&entityId=<uuid 或短号>[&space=][&limit=][&cursor=]`，需 `comments:read` **+ 宿主的 read 档**（任务讨论 = `comments:read` + `tasks:read`）。

- **令牌必须锚定宿主**：缺 `entityType`/`entityId` 返 `400 entity_required`。
- `entityId` 与 URL 里的 `:id` 同款寻址：uuid、`T383`、裸 `383`（后两种在访问集内解析，撞号返 `400 ambiguous_seq`，见「短号寻址」；`&space=` 可直接定位）。
- **第一页取的是最近 N 条**（缺省 200），**页内按时间正序**返回，读起来仍是对话顺序。分页按「分页的总口径」，**下一页是更旧的一批**：要补更早的上下文就把 `nextCursor` 放进 `?cursor=` 接着翻，直到 `hasMore: false`。
- 每条形如 `{id, entityType, entityId, parentId?, kind, author, authorId?, via?, bodyText, bodyHtml, at, editedAt?, spaceId}`。`bodyText`/`bodyHtml` 与实体正文同一套词汇（PM 源不下发）；`parentId` 是回复关系；`kind:"ai"` 是网页端 AI 生成的答复（可带 `aiMeta`），`kind:"user"` 是人写的（或经令牌代发，见 `via`）。`at` 是发表时刻，`editedAt` 是正文最后一次被改的时刻——**没有这个键就是从未改过**。

**写**：`POST /api/comments` body `{entityType, entityId, bodyMarkdown}`，需 `comments:write` **+ 宿主 read 档**。

- `bodyMarkdown` 与实体正文**同一套 GFM 子集**，包括 `#T12`/`#G7`/`#N3` 短号引用（渲染成可点击 chip；评论里的引用不建关联边，见「短号寻址」）。只写一两句纯文本用 `bodyText`。评论正文单字段上限 64 KiB（比实体正文更紧——评论是高频小对象）。
- `id` 可省（服务端生成）。`parentId` 可选，但必须指向**同一宿主上未删除**的评论，否则 `400 invalid_parent`（跨空间的 id、已软删的父评论都算）。
- 令牌**不能**发 `kind:"ai"`（`400 ai_kind_forbidden`）；`aiMeta`/`regeneratedFromId` 对令牌一律丢弃。
- `json`/`html` 对令牌**不开放**，一律丢弃。所以**只发 `html`/`json` 等于没写正文**，会返 `400 body_required`（正文什么都不带、或只给了空白 `bodyMarkdown`/`bodyText` 时同理）。`content` 在 POST 上等价于 `bodyText`（当纯文本收，自动转义），在 PATCH 上则整个忽略；要结构一律写 `bodyMarkdown`。

**作者不能指定**：令牌写的评论**作者恒是令牌持有人**——`author` 落持有人的显示名、`authorId` 落其用户 id，调用方传的 `author` 一律丢弃。（`via` 是另一个维度，标经哪枚令牌写入，见「写入的身份分两个字段，别混」。）

**改与删**：

- `PATCH /api/comments/:id` body `{bodyMarkdown | bodyText}`（需 `comments:write` + 宿主 read 档）——令牌**只能改自己写的那些**（`authorId` = 持有人），碰别人的返 `403 forbidden`；网页端的 AI 评论（无 authorId）令牌永远改不动。新正文**整条替换**旧的。同样只认 body* 通道，`content`/`json`/`html`/`author` 一律忽略。空白 `bodyMarkdown`/`bodyText` 在这里是**清空正文**（改的时候有正文可清），创建时则算没写正文（`400 body_required`）。
- `DELETE /api/comments/:id`（需 `comments:delete` + 宿主 read 档；`write` 不含删）——再叠持有人本人的删除权：评论作者本人，或该空间的管理员。

### 任务状态与阶段（四轴 + 生命周期动词）

任务的状态不是单一字段，而用四个**正交轴**表达（列表/详情/搜索结果一致）：

- `state`：`open` | `closed`
- `resolution`：`completed` | `dropped` | `duplicate` | `null`（仅 closed 非空）
- `activity`：`not_started` | `active` | `paused` | `null`（仅 open 非空）
- `stage`：当前工作流阶段名 | `null`

**状态与阶段的变更只经动词端点**（都需 `tasks:write`）：

- `POST /api/tasks/:id/start` — 开始处理（→ `active`）。
- `POST /api/tasks/:id/hold` body `{onHoldUntil?, onHoldReason?}` — 暂停（→ `paused`）。
- `POST /api/tasks/:id/resume` — 恢复（`paused` → `active`）。
- `POST /api/tasks/:id/move` body `{stage}` — 推进到工作流阶段，**并一并置 `activity: active`、清空 `onHoldUntil`/`onHoldReason`**（stage 只在 active 态有意义）——对 `paused` 任务 move 即解除暂停。非法阶段返 `400 invalid_stage`（附 `validStages`）。这条还会在任务没有负责人时补上负责人，见「负责人」一节。
- `POST /api/tasks/:id/close` body `{resolution: completed|dropped|duplicate, closeReason?, closeNote?, duplicateOfId?}` — 关闭。`duplicate` 必须带 `duplicateOfId`，否则 `400 duplicate_needs_target`。
- `POST /api/tasks/:id/reopen` body `{activity?: not_started|active}`（默认 `not_started`）— 重开（`closed` → `open`）。

守卫：`closed` 不能 start/hold/move（`409 not_open`，需先 reopen）；非 `paused` 不能 resume（`409 not_paused`）；非 `closed` 不能 reopen（`409 not_closed`）；没有阶梯的任务不能 move（`409 no_ladder`，见下）。

**发现合法阶段**：`GET /api/tasks/:id` 给令牌的响应除 `task` 外含 `ladder`——`{ stages, current, qaOn }` 时 `stages` 就是该任务能 `move` 到的阶段集合（随任务的工作流而定）；`null` 表示该任务没有阶梯，stage 轴对它不适用，状态推进只用 start / hold / resume / close。**move 前先读它，别猜阶段名。**

### 排序：`?order=`

`GET /api/tasks` 收两个值：

- **`updated`（缺省）**：`updatedAt` 倒序，最近动过的在前。**不表示优先级。**
- **`gravity`**：引力序，即人在界面上看到的顺序。问「接下来做什么」用它，见下。

两者都给出**确定的**顺序：并列条目也有稳定的兜底次序（退回缺省序），同一份数据两次请求的结果一致。**拼错会 `400 invalid_order` 并附上合法集**（见「查询参数的总口径」）。

#### 引力排序：`?order=gravity`

- 结果已按引力序排好，按返回顺序读即可；每条任务多一个 `rank: {tier, blocked, wsjf, inheritedFrom, terms, effortDays}`。
- `tier`：排序档位，小的靠前；`99` = 不可执行（已关闭 / 暂停）。它可能与任务自身的 `prio` 不同，此时 `inheritedFrom` 给出相关任务的 id，否则为 `null`。
- `blocked`：还有未清的依赖（见「任务依赖」）。**可执行性看这一位，不看 `tier`** —— `blocked: true` 的任务推不动，别当成可开工的。
- `wsjf`：同档内的紧迫度分数，大的靠前；受截止（`dueAt`）、目标进度与目标价值权重（`priority`）、工作量（`estimateDays` / `energy`）等影响。
- `terms` / `effortDays`：`wsjf` 的构成，`wsjf` = 各项 `value` 之和 ÷ `effortDays`（人日）。每项 `{key, value}`，按需另带 `goalId`（这一项来自哪个目标）、`days`、`pct`。`key`：`floor`（基础分）、`momentum`（已在进行）、`goalPriority`（所属目标链上的价值权重）、`goalClosing`（所属目标接近完成，`pct` 为其进度）、`overdue` / `dueSoon`（逾期 / 临近截止，`days` 为天数）、`stale`（没有截止的待办已放了 `days` 天）、`ownership`（指派给你）、`cycleCommit` / `cycleEnd`（承诺在当前周期 / 周期临近结束）；不认识的 `key` 照样计入总和。`tier` 为 `99` 时 `terms` 为 `[]`、`effortDays` 为 `null`。向用户解释「为什么排在前面」用这两项，与网页端的排序解释同源。
- 顺序与网页端显示的一致。

只作用于列表：单实体 GET 不带 `rank`。**不与 `?since` 组合**（那是增量同步语义，含软删墓碑）——这个组合返回 `400 order_unsupported_with_since`。可与时间窗、`?archived=1` 组合，筛过之后分数不变。

### 关联（`links`：实体之间的边）

**读**：并在三类实体的单实体 GET 里（与 `ladder`、`commentCount` 同处），恒给：

- `links: [{relation, dir, type, id, shortId, spaceId, title, …}]` — 与本实体相连的边，**两个方向都给**：`dir` 为 `out` 是本实体指向对方（关联），`in` 是对方指向本实体（被引用）。`relation` 见下表。附件边（指向网盘文件）不在其中。
- `linksTruncated: bool` — 单个方向超过 200 条时截断，截掉的是更旧的边。**没有续取的办法**，那些边拿不回来（见「分页的总口径」末尾）。
- `blocked: bool` —— **仅任务**：还有没清完的硬前置。`blocked:true` 的任务**别当成可开工的**。

每个条目还带对方**行上的状态**：任务给 `state`/`resolution`/`activity`（同四轴词汇）与 `archived`，目标给 `archived`，知识没有状态轴。

`relation: "depends"` 的**出边**另带 `done` = 这条前置是否已清：`state` 为 `closed` 即算清，`resolution` 是 `completed` 还是 `dropped` 都一样。唯一的例外是 `resolution: "duplicate"` —— 它跟着 `duplicateOfId` 那条走，顺着链一直看到第一个非重复的任务；解析不到那条时算清。

⚠️ `done: true` 只说明**不必再等它**，不等于那件事做成了：前置被 `dropped` 时也是 `true`。要区分就看同一条边上的 `resolution`。

`title` 是唯一按令牌访问集收窄的字段：

- `title` 为 `null` 而 `spaceId` 有值 —— 对方在**你的令牌没被授权的空间**里。标识与状态照给，拿 `spaceId` 与 `/api/me` 的 `spaces` 一比就知道够不着；需要它的详情就请用户另发一枚覆盖该空间的令牌。
- `title` 与 `spaceId` **都**为 `null` —— 对方已删除、或不在令牌持有人的任何空间里。这类条目**保留不略去**，且仍计入阻塞。

关系词表：`ref`（参考）、`depends`（硬前置）、`derived`（衍生自）、`origin`（沉淀，知识源自工作）、`aligns`（目标对齐）。

**写**：只从任务出发，放行 `depends`、`ref`、`derived`；其余关系返 `400 relation_not_writable`（附 `writable` 集）。从知识或目标出发的关联，在它的正文里写短号引用。两个入口，语义**刻意不对称**——

- 创建时内联：`POST /api/tasks` body 里带 `links: [{"to": "T361", "relation": "depends"}]`。`to` 收短号（带类型字母，**推荐**）、裸数字（按任务解析）或 uuid；短号按**新实体落库的那个空间**解析。重复的目标自动去重。有任何一条不合法就整体失败、**一行都不写**（不会留下半成品实体）。
- 之后改关联：**只能一条一条来**。
  - `POST /api/tasks/:id/links` body `{"to": "T361", "relation": "depends"}` — 加一条（幂等：已有这条边再加一次不报错）。
  - `DELETE /api/tasks/:id/links/:relation/:to` — 撤一条；`:to` 同款寻址。本来就没有这条边返 `404 link_not_found`。**只需 `tasks:write`**（与加同档：能加就能撤，方向搞反了自己修得回来）。

**`ref` / `derived` 的目标**可以是任务、目标或知识：`to` 写 `T12` / `G7` / `N3`、裸数字（按任务解析）或 uuid。

**正文引用来的 `ref` 边**：正文里写 `#N3` 时，本任务到 `N3` 已有一条 `ref` 边，它随正文维护（正文里删掉引用，边随之撤掉）。对这条边——

- 加同一条 `ref`：视为已存在，返回成功，**不改变**它随正文维护的性质。
- 撤它：返 `409 link_from_body`。要撤，改正文、删去那处引用。

⚠️ `relation` **必须显式给**（路径里也是）：五种关系语义完全不同质，同一对实体之间可以同时挂着两条不同关系的边，不指名就定不下来是哪条。

⚠️ **`links` 只在创建时可用**，PATCH 带它返 `400 use_links_endpoint`；改关联用上面的颗粒化端点。

**反引用（`dir: "in"`）**：「谁引用了这条实体」读它自己的单实体 GET、筛 `dir: "in"` 的条目。入边不能从本实体这一侧增删——上面的写入口只作用于**出边**，`DELETE /api/tasks/:id/links/:relation/:to` 撤的是本任务指向 `:to` 的那条边，拿它撤指向本任务的入边匹配不到任何边（返 `404 link_not_found`；不可写的关系更早一步返 `400 relation_not_writable`）。要撤一条正文短号引用来的入边，改**来源方**的正文（见「短号寻址」）。

**依赖只在任务之间**（`400 depends_task_only`）：`to` 只收任务短号（`T361`）、裸数字或任务 uuid。目标与知识都没有「做完」这个动作，拿它们当前置就是一条永远清不掉、或随子树进度上下摆动的阻塞。要表达「等这个目标推进到位」，把那件具体的事建成任务再依赖它；只是想关联某个目标／某条知识，在正文里写 `#G7` / `#N3` 短号引用，或加一条 `ref` 边。

**依赖的两端必须在同一空间**（`400 link_cross_space`）：跨空间的依赖不参与阻塞判定与排序，建出来也不起作用，所以服务端直接拒收——给 uuid 也一样。想记下「另一个空间的那件事」，在正文里写它的短号引用。

**成环会被拒**（`409 depends_cycle`，自依赖是 `400 depends_self`）。

**一批新任务内部的前后依赖**：`POST /api/tasks` 的 body 认客户端供的 `id`，所以自己铸 id、按序创建、后面的 `links` 引用前面的 id 即可（内联 `links` 只能引用**已存在**的实体）。`id` 只在创建时可用，PATCH 带它返 `400 create_only_field`。

### 归属目标（`goalId` / `parentId`，解析好的链在 `goalChain`）

**是哪一个**：任务挂的目标 = `task.goalId`，目标的上级 = `goal.parentId`。uuid，行上恒有，未挂目标 / 顶层目标时为 `null`。**读写同名**——改挂也是这两个字段。

**叫什么、多重要、上面还有谁**：单实体 GET 给令牌在信封里多带一个 **`goalChain`**，与 `task` / `goal` 平级（**不在 `task` / `goal` 对象内**）：数组，**leaf → root**（实体所属目标在前，顶层目标在末），每层 `{id, shortId, title, spaceId, priority}`。它是上面那条链**已经解析好**的版本，**按 `id` 对上你手里的 `goalId` / `parentId`** 即可，不必记下标。

- 任务没挂目标时整个数组是 `[]`；目标的链**含自身**（顶层目标的链是 `[self]`）。
- 某一层**已删除或查不到**就在那里**止步**——给到自本实体向上**连续可见**的那一段。
- `title` 与 `spaceId` 的读法同 `links` 的条目（见上）：`title` 为 `null` 而 `spaceId` 有值 = 它在**你的令牌没被授权的空间**里。这样的层照给标识与 `spaceId`，链继续往上。

**做到哪一步**：目标的单实体 GET 另带 **`progress`**（与 `goal` 平级）：`{pct, source, achieved, krPct, taskPct}`。`pct` 是网页端目标进度条上的那个数（0–100，含子目标的加权上卷）；`source` 说明它从哪来：`kr`（按 KR）、`task`（按直接挂的任务）、`children`（按子目标与自身度量加权）、`kr-task-fallback`（KR 都还没动，按任务算）、`none`（没有可度量的东西，`pct` 为 0）；`achieved` 为 `true` 即已达成。任务所属目标的进度，取 `goalChain` 里那一层的单实体 GET。

⚠️ 信封里**没有** `goal` / `parent` 这两个键（`task.goal` / `goal.parent` 同样没有）。读它们得到的 `undefined` 与「未挂目标」不可区分——用本节开头那两个字段。

### 相关（`/:id/recall`：读这条时顺带看相关）

`GET /api/tasks/:id/recall`、`/api/notes/:id/recall`、`/api/goals/:id/recall`（分别需 `tasks:read` / `notes:read` / `goals:read`）。`:id` 与详情 GET 同款寻址（uuid 或短号）。返回与这条实体语义最相关的条目——不必自己想查询词，也不消耗 embedding 额度。

```
{ "hits": [{ "kind": "note", "id": "…", "shortId": "N12", "title": "…", "score": 0.72 }] }
```

- 按相关度降序，**最多 3 条**；`kind` 为 `note` 或 `task`，`score` 的量纲同 `/search`。
- 语料按被读的那条实体定：**目标**与**知识**只召回知识，**任务**召回知识 + 写了正文的任务（只有标题、没写正文的任务不进语料）。范围恒是**该实体所在的那个空间**。
- **已经建了关联的实体不会出现在 `hits` 里**（两个方向都算）——那些关系发一次详情 GET 就能看到（见「关联」一节），召回位留给新的。
- **相关度不够的条目不给**：`hits` 可能不足 3 条，也可能是空数组。空数组是「问过了，没有够相关的」，**不是出错**。
- 召不出时 `hits` 为 `[]` 并附 `reason`，主要两种：`no_anchor_vector`（这条实体还没有可比的语义向量——例如只有标题没有正文）、`org_gate_off`（管理员关掉了语义召回）。**不会降级成关键词匹配**，所以 `hits` 里的东西要么是语义相关的，要么没有。
- 有限流（与网页端召回面板共用额度），超限返回 `429 abuse_limit_exceeded`（`scope: "recall"`），稍候重试。**详情 GET 不受它影响。**

与 `/search` 的分工：**`/:id/recall` 是「读这条时顺带看相关」**——查询就是这条实体自身；**`/search` 是「按内容找」**——你给查询词，可跨空间。找东西用 `/search`，别为此先拉一条实体的详情。

⚠️ **它是一次独立请求，不在详情 GET 的信封里。** 这样拆是为了让详情 GET 的 `ETag` 只随该实体自身变化——召回依赖整个空间的语料，并进详情就会让条件请求（`If-None-Match` → `304`）基本失效，而详情是这套 API 里最胖的响应之一。所以：**要正文就发详情 GET（可缓存），要相关项就发这一条。**

### 标签（task 与 note 通用）

标签随「写」权限授予（`tasks:write`/`notes:write`），无需单独的标签权限。两个入口，语义**刻意不对称**（与依赖同律）：

- **创建时内联**：`POST /api/tasks` 或 `/api/notes` 的 body 里带 `tagNames`（字符串数组，如 `["销售","P0"]`）一次设好。同名自动复用（大小写/空白不敏感），新名自动创建。
- **之后改标签：只能一条一条来。**
  - `POST /api/tasks/:id/tags` body `{"name": "P0"}` — 挂一条（**幂等**：已经挂着再挂一次不报错）。`name` 收标签名（不存在则在该实体所在空间新建）或已有标签 id。
  - `DELETE /api/tasks/:id/tags/:tag` — 摘一条；`:tag` 同样收名字或标签 id。**只需 `tasks:write`**（摘的是实体的属性，不是标签本身）。本来就没挂着返 `404 tag_not_found`。
  - 知识侧同构：`/api/notes/:id/tags`。**目标没有标签轴。**

⚠️ **写标签只认 `tagNames`，且只在创建时可用。** PATCH 带 `tagNames`、创建或 PATCH 带只读字段 `tags`（读回来的实体上标签挂在它下面），都返 `400 use_tags_endpoint`；改标签用上面的颗粒化端点。

单个实体最多 50 个标签（`400 too_many_tags`）；幂等地重挂一条已有的不占名额。

**打标签前先查已有标签**：`GET /api/tags`（需 `tags:read`）返回当前可见的标签列表，**请优先从中挑选复用**规范标签，避免造近义重复（如已有「客户」就别再建「顾客」）。标签的重命名/合并/删除只在网页端做，token 无法改动共享标签。

**按标签找条目**：list 的 `?tags=`（见上面 `GET /api/tasks` 的参数清单），别拉全量再自己筛。

### 创建目标：`POST /api/goals`

body 至少 `{ "title": "…" }`，其余可选：

- `parentId` — 上级目标的 uuid，**省略即顶层目标**（见下面的守卫）。
- `spaceId` — 落点，同任务。
- `priority` — 价值权重，枚举 `normal` | `key` | `critical`（缺省 `normal`）。**不是任务的 `prio`**，也不收数字。
- `deadline` / `startAt` — 截止与开始日。**截止叫 `deadline`，不是 `dueAt`**；两者的取值形式与时区折算同任务（见「分类、工作量与时间字段」）。
- `icon`（emoji）/ `color`（CSS 色值，如 `#6c8cff`）。
- `bodyMarkdown` / `bodyText` — 正文，同任务（见「正文读写」）。

`keyResults` 不在其中，见下面「目标 KR」。

⚠️ **`framework` / `metric` / `target` / `current` 已退场**：发过来照常成功（不报 `unknown_field`），但**不落库、也不在任何读响应里下发**。要量化一个目标，用 KR（见下面「目标 KR」）。

⚠️ **目标没有任务那几个轴**：`prio` / `kind` / `domain` / `energy` / `estimateDays` / `dueAt` / `goalId` / `tagNames` 发到目标端点返 `400 unknown_field`，`hint` 指名该用哪个字段。挂上级用 `parentId`，设期限用 `deadline`，优先级用 `priority`。

三条守卫：

- **在团队空间建顶层目标（不给 `parentId`）要该空间的管理员**，否则 `403 forbidden`；给了 `parentId` 的子目标普通成员即可。把已有子目标改成顶层（PATCH `parentId: null`）是同一道门，报 `403 root_goal_admin_only`。个人空间不受这道门限制。
- **`priority: "critical"` 每个空间限 3 个**（归档与已删的不占名额），超了返 `400 critical_goal_limit`。
- **`parentId` 的三种失败**：查不到 `400 parent_not_found`；父目标与本次落点不在同一空间 `409 scope_conflict`；目标树超过 10 层 `400 goal_depth_exceeded`。

**目标 KR（关键结果）**：日常增改删**优先用颗粒化端点**（单条安全，无整份误删风险）：

- `POST /api/goals/:id/keyResults` body `{title, target?, current?, mode?}` — 追加一条（返回 `keyResultId` + 整个目标）。需 `goals:write`。
- `PATCH /api/goals/:id/keyResults/:krId` body `{current?, title?, target?, mode?}` — 改单条（最常用：只更 `current`）。需 `goals:write`。
- `DELETE /api/goals/:id/keyResults/:krId` — 删单条（不删指向它的任务）。KR 不在该目标下返 `404 kr_not_found`。**只需 `goals:write`**（KR 是目标的属性，与 add/set 同档；`goals:delete` 是软删整个目标用的，含级联子目标）。

在 `POST`/`PATCH /api/goals` 的 body 里带整份 `keyResults` 数组**不对 API 令牌开放**（返 `400 use_kr_endpoint`），一律用上面的颗粒化端点。

**目标归档**（需 `goals:write`，另需**持有人本人**在该空间的删除权——管理员，或该目标的创建者，否则 `403 forbidden`）：

- `POST /api/goals/:id/archive` body `{outcome?, closeNote?}` — 归档该目标**及其整棵子树**（子目标一起隐藏）。挂在下面的任务/知识**保持关联**，不被摘走。
  - `outcome` — 复盘结论，枚举 `achieved`（达成）| `missed`（未达成）| `abandoned`（放弃）| `deferred`（延期），或 `null`。
  - `closeNote` — 一句话收尾说明，或 `null`。
  - 两个字段只落在**你归档的那个根目标**上，不下发给子树。重复归档即刷新这两个字段。
- `POST /api/goals/:id/unarchive` — 恢复整棵子树，并清掉根上的 `outcome` / `closeNote`（再归档时重新填）。

归档目标下的任务默认不出现在 `GET /api/tasks` 里，要 `?archived=1` 单拉（见上面的「归档」一条）。

## 周期（冲刺横轴）

一条任务有两条正交的轴：`goalId` 说的是**它属于什么**，`cycleId` 说的是**承诺在哪个周期做**。

**周期不是你建的。** 服务端按空间设置自动滚动切分时间轴，任何时刻都恰好有一个 `status` 为 `active` 的周期，所以没有创建与删除端点。要换切分就改设置。

- `GET /api/cycles[?space=<slug | id | me | all>][&since=<ms>]`（需 `tasks:read`）→ `{ cycles, settings }`。
  - `cycles[]`：`{id, spaceId, name, startAt, endAt, status}`。`startAt`/`endAt` 是**日历日**（`YYYY-MM-DD`），`endAt` **右开**——`9/14 – 9/20` 那一期的 `endAt` 是 `2026-09-21`。`name` 为空串 = 没起过名字（显示时回落到日期区间）。`status`：`planned` | `active` | `closed`。
  - `settings[]`：一空间一条 `{spaceId, days, anchor, dayUnit}`。`days` = 窗口长度（自然日），`anchor` = 切分锚点（`YYYY-MM-DD`），`dayUnit` = 天数读数单位（`workday` | `calendar`）。⚠️ `dayUnit` 换的是**读数单位**，不是窗口定义：窗口边界永远按自然日切。
- `GET /api/cycles/settings?space=<一个空间>`、`PATCH /api/cycles/settings?space=<一个空间>`（读需 `tasks:read`，写需 `tasks:write`）。body 收 `days` / `anchor` / `dayUnit`。⚠️ `?space=` 在这两个端点上**不收 `all`**：一个范围改不了某个空间的设置。改设置要空间管理员（个人空间是它的主人），否则 `403 forbidden`。
- 周期的 `name` 不对令牌开放（`403 session_only`）。窗口边界与 `status` 都是**算出来的**，同样不接受写入。

**承诺、顺延、撤回**：

- 承诺 / 撤回都是 `PATCH /api/tasks/:id`（需 `tasks:write`）写同一个字段：`{"cycleId": "<周期 uuid>"}` 是承诺，`{"cycleId": null}` 是撤回承诺（回 backlog）。⚠️ `cycleId` **只收 uuid**——周期没有短号。周期与任务必须**同空间**，否则 `409 scope_conflict`；id 不存在是 `400 cycle_not_found`。
- `POST /api/tasks/:id/defer`（需 `tasks:write`）——顺延到下一周期。它做两件 `PATCH` 做不到的事：把任务上的 `cycleDeferCount` 加一，以及算出「下一期是哪个」（要读空间的周期设置，还可能就地把那个窗口物化出来）。响应 `{task, cycle}`。还在 backlog 的任务没有「顺延」可言 → `409 not_committed`。

**任务行上的三个字段**（读侧）：

- `cycleId` — `null` = **backlog**，即还没做出承诺。⚠️ 它与「有没有设 `dueAt`」**完全无关**：截止日说的是「什么时候必须完成」，承诺说的是「打算在哪一期做」。别拿 `dueAt` 去推 `cycleId`。
- `cycleJoinedAt` — 这次承诺是什么时候做出的。晚于所属周期的 `startAt` = 中途加入（范围蠕变的信号）。
- `cycleDeferCount` — 顺延次数，只增；撤回承诺不清零。

CLI：`sotogoal.mjs cycles`、`sotogoal.mjs cycle set <任务> <周期id|backlog>`、`sotogoal.mjs cycle defer <任务>`。

**阶梯流转事件（日报/周报的聚合原料）**：`GET /api/stage-events`（需 `tasks:read`）返回任务状态/阶段变更的只读审计流，按 `at` 升序。范围（互斥）：`?taskId=<uuid>`（单任务时间线）、`?space=<空间 slug 或 id>`（整个空间，需该空间开通流程分析，未开通返 `403 feature_locked`；`?space=me` = 个人空间，不受该付费门限制）、都不带 = **落令牌的默认空间**（没有默认空间时返 `400 space_required`）。过滤：`?after=&before=`（ISO 或 epoch ms，含/不含）裁时间窗；`?actor=me` 按**操作者**过滤——actor 是触发这次流转的人，**不是任务负责人（assignee）也不是创建者**：「我完成了什么」适用，「指派给我的任务被别人推进」不覆盖（按负责人聚合请 list 拉任务后按 `assigneeIds` 分组）。也接受与你同空间的成员 userId（取自事件行的 `actorId`）。每条事件形如 `{taskId, spaceId, actorId, at, from, to}`，`from`/`to` 是四轴视图 `{state, resolution, activity, stage}`（`from=null` 表示初始事件）。典型用法：`to.state==="closed"` 数本周关闭吞吐（含 dropped/duplicate，行上 `doneAt` 只记 completed）、`stage` 变化算各阶段停留时长/瓶颈。

**语义搜索（note / task / goal 三类）**：`GET /api/notes/search`、`/api/tasks/search`、`/api/goals/search`，参数 `?q=<自然语言>&limit=10&minScore=<可选>&space=<可选>`（分别需 `notes:read`/`tasks:read`/`goals:read`）。语义相关排序，语义不可用时自动降级关键词，返回瘦结果 `{ mode, reason?, results:[{id,seq,title,snippet,tags?,score,spaceId, state?,resolution?,activity?}] }`（任务结果带 `state`/`resolution`/`activity` 三轴），不含向量。**不给 `?space=` 就是搜遍所有可见空间**（见「权限与空间」），每条结果的 `spaceId` 标明出处，回传定位用 `id`；要收窄写 `?space=<slug | id | me>`（`all` 为 no-op）。**按内容找条目用它**，别 list 全量再自己筛；**「不知道东西在哪个空间」也先搜一次**，别拿收窄过的列表下「不存在」的结论。

- `minScore`：只回 `score ≥ 阈值` 的结果。**量纲随 `mode`**——语义(`mode:"semantic"`)时是余弦相似度（0–1）；关键词降级(`mode:"keyword"`)时是 token 重叠计数（整数）。先看响应的 `mode` 再定阈值。
- **不给 `minScore` 时语义模式自带门槛**：只回相关度达标的结果，弱相关不返回，**可能返回空数组**——那是「没有足够相关的内容」这个答案，不是故障。想拿回全部候选（含弱相关）写 `minScore=0`；想更严就往上调。门槛值随部署的 embedding 模型标定，不是固定常数，别把它写死进你的逻辑。关键词模式无此默认门槛（token 重叠本身就是门槛）。
- **`mode` 可能是 `keyword`**（这次没走语义，`score` 的量纲随之改变——见上面 `minScore` 那一条）。此时响应带 `reason`，说明为什么：

  | `reason` | 含义 | 你的下一步 |
  | --- | --- | --- |
  | `org_gate_off` | 管理员关掉了语义召回 | 本轮别指望语义；要开得找管理员 |
  | `no_embedding_key` | 本部署没有配置 embedding | 同上（自托管则由部署方配置） |
  | `embed_failed` | 这一次算查询向量失败 | 可重试 |
  | `corpus_not_embedded` | 范围内有条目，但没有一条带当前模型的向量 | 请管理员跑一次「补建向量索引」 |
  | `empty_corpus` | 范围内一条条目都没有 | 结果必然为空；放宽 `?space=` 或换个空间再搜 |
  | `no_semantic_hit` | 语义算过了，没有一条相关度为正 | 换措辞重搜；本次结果是关键词匹配出来的 |

  `mode:"semantic"` 时不带 `reason`。
- 有限流，超限返回 `429 abuse_limit_exceeded`（`scope: "search"`），稍候重试。

## 错误码速查

- `401 invalid_token` / `token_expired` — 令牌无效或过期 → 让用户去账户设置重建。
- `403 insufficient_scope`（响应含 `need`）— 令牌缺该权限。按 `need` 的字面值请用户补授权（评论端点上它可能指的是宿主那档）。
- `403 space_forbidden` — `?space=` 指向令牌访问集之外的空间。宿主/实体越界是另一回事，见「越界报 403 还是 404」。
- `403 session_only` — 该端点不对令牌开放，请改用本文档列出的端点。
- `403 feature_locked`（响应含 `feature`）— 该空间未开通此付费功能（如流转事件的空间范围查询）。告知用户，别重试。
- `403 task_limit_reached`（响应含 `used` / `limit`）— 该订阅账户的**未完成**任务数已达上限。关闭或归档一批任务即释放额度（已关闭的不占额度，**不必删**），或请用户升级订阅。重试不会自己好。除创建之外，`reopen`、**从回收站恢复一条任务**（以及取消归档、把任务挪到未归档的目标下）同样会撞它——那也是在增加未完成任务数。
- `403 revision_out_of_window`（响应含 `windowDays` / `at`）— 这一版正文超出该空间可回溯的天数。改取列表里较新的版本；最新的那一条不受此限。见「正文历史」。
- `403 forbidden` — 令牌**持有人本人**在该空间的角色不够，与 scope 无关（补授权没用，得请该空间的管理员来做）：在团队空间建顶层目标、删除或归档不是自己建的目标、**从回收站恢复不是自己建的实体**都要管理员；改别人写的评论也报它。
- `403 root_goal_admin_only` — 把团队空间里的子目标改成顶层目标（PATCH `parentId: null`）要管理员。
- `400 use_lifecycle_action` — 试图用 PATCH/create 直接改任务的状态或阶段。改用生命周期动词（start/hold/resume/move/close/reopen）。
- `400 use_kr_endpoint` — 试图用 POST/PATCH `/api/goals` 带整份 `keyResults`。改用 KR 颗粒化端点（kr add/set/rm）。
- `400 create_only_field`（响应含 `field` / `hint`）— 该字段只在创建时可用，PATCH 不受理：`spaceId`（落点）与 `id`（标识，创建时可自带）。去掉它重试。
- `400 critical_goal_limit` — 该空间的「关键」目标已达上限（3 个）。改用 `priority: "key"`，或先把别的关键目标降档。
- `400 invalid_space_id`（响应含 `field` / `hint`）— 空间字段不在值域内（`spaceId`）：给了空串、纯空白或空间 slug。它收的是空间 id：个人空间写 `me`，团队空间写 `/api/me` 的 `spaces[].id`，省略这个字段才是「落默认空间」。（`?space=` 是另一回事，它收 slug 或 id。）
- `400 space_required`（响应含 `field` / `hint`）— 本令牌够得着多个空间（`/api/me` 的 `spaces` 多于一项），没有默认空间可落：创建落点写 `spaceId`，列表与报表写 `?space=`。见「权限与空间」。
- `400 parent_not_found` — `parentId` 指向的目标不存在，或不在令牌的可见范围内。
- `409 scope_conflict` — 父目标与本次落点不在同一空间。目标只能挂在**同空间**的父目标下：换 `spaceId`，或换一个同空间的父目标。
- `400 goal_depth_exceeded` — 目标树深度已达上限（10 层）。挂到更浅的父目标下。
- `400 unknown_param`（响应含 `validParams`，可含 `paramEndpoints`）— 本端点不受理这个查询参数：拼错了名字，或发错了端点。见「查询参数的总口径」。
- `400 unknown_field`（响应含 `unknownFields` / `validFields` / `hint`）— 本端点不受理这个 body 字段：拼错了名字，或照另一类实体的字段名写的。按 `hint` 换字段。见「body 字段的总口径」。
- `400 invalid_since` — `?since=` 既不是 epoch 毫秒（`1750000000000`）也不是 ISO 8601 时刻（`2026-09-01T00:00:00Z`）。
- `400 invalid_doneAfter` 等 — 时间窗参数读不出时刻，取值同 `invalid_since`。错误码是 `invalid_` 加参数名，六个报表时间窗与流转事件的 `?after=` / `?before=` 同此。
- `400 invalid_archived`（响应含 `validValues`）— `?archived=` 的值不认识。合法取值见 `GET /api/tasks` 的「归档」一条。
- `400 invalid_type`（响应含 `validValues`）— `GET /api/trash` 的 `?type=` 里有读不懂的值（拼成复数、类型写错）。合法值见「回收站」一节。
- `400 invalid_limit` — `?limit=` 不是正整数。
- `400 invalid_cursor` — `?cursor=` 不是上一页给的那个 `nextCursor` 原值，或本次的排序/过滤参数与取到它的那次请求不一致。去掉它从第一页重新翻。见「分页的总口径」。
- `400 pagination_unsupported_with_since` — `?since=` 与 `?limit=`/`?cursor=` 同时给了。`?since=` 整段回、不分页，去掉分页参数。
- `400 invalid_minScore` — `?minScore=` 不是数字。
- `404 actor_not_found`（响应含 `unresolved`）— `?actor=` 给的 userId 不是与你同空间的成员。自己写 `me`；别人的 userId 从事件行的 `actorId` 取。
- `400 invalid_seq`（响应含 `hint`）— `?seq=` 给了但解析不出来（非数字、类型字母与端点不符、逗号列表里有坏值、空值或纯空白）。**参数在场即算「给了」**。
- `400 invalid_goal`（响应含 `hint`）— `?goal=` 给了但读不出来（既不是目标短号也不是 uuid、类型字母不是 `G`、逗号列表里有坏值、空值或纯空白）。判据同 `invalid_seq`。
- `400 ambiguous_goal`（响应含 `candidates`，每条带 `shortId` / `spaceId` / `title`）— `?goal=` 的短号在多个空间里都有同号目标。挑一条候选，按它的 `spaceId` 补 `?space=`，或改给目标 uuid。
- `404 goal_not_found`（响应含 `unresolved` = 没解析到的那几个 token）— `?goal=` 里**任一**目标在本次生效的空间范围内解析不到。给出的目标必须全部存在，不会只回解析到的那几个。补 `?space=` 指向它所在的空间——**给 uuid 不能绕过这一步**（见「`?space=` 管什么」）。
- `400 invalid_order`（响应含 `validOrders`）— `?order=` 的值不认识。合法值见「排序」一节。
- `400 order_unsupported_with_since` — `?order=gravity` 与 `?since` 同时给了。去掉 `?since` 再要引力序。
- `400 use_links_endpoint` — PATCH 带了 `links`。改用 `POST`/`DELETE /api/tasks/:id/links`。
- `400 relation_not_writable`（响应含 `writable`）— 这种关系不对令牌开写，可写的关系见 `writable`。
- `400 use_body_short_ref`（响应含 `field`）— 创建或 PATCH 知识时带了 `goalId`/`taskId`/`links`。知识与目标/任务的关联在正文里写短号（`#G7` / `#T12`）建立。
- `400 use_tags_endpoint` — 创建或 PATCH 带了只读字段 `tags`，或 PATCH 带了 `tagNames`。创建时写标签用 `tagNames`，之后改标签用 `POST`/`DELETE /api/tasks|notes/:id/tags`。
- `404 tag_not_found` — 两处：摘标签时（响应含 `target`）那条标签本来就没挂在这个实体上（标签在别处存在也算）；list 的 `?tags=` 里（响应含 `unresolved` = 没解析到的那几个 token）任一标签在本次生效的空间范围内不存在，此时换 `?space=` 或先 `GET /api/tags` 对一下名字。
- `400 invalid_tags`（响应含 `hint`）— `?tags=` 给了但一个 token 都没有（空值、纯空白、只有逗号）。判据同 `invalid_seq`。
- `400 too_many_tags`（响应含 `limit`）— 单个实体的标签数已达上限（50）。先摘一条再挂。
- `400 depends_task_only` — 拿一个目标（`G…`）或一条知识（`N…`）当前置依赖。依赖只在任务之间建立，换成任务短号。
- `400 depends_self` / `409 depends_cycle`（响应含 `target`）— 自依赖 / 这条边会让依赖链成环。检查一下方向：`depends` 是「本任务依赖 to」，不是反过来。
- `404 depends_target_not_found`（响应含 `target`）— 前置任务不存在、不在可见范围，或给的 uuid 不是任务。短号在**宿主任务所在空间**里解析。
- `400 link_cross_space`（响应含 `relation` / `fromSpaceId` / `toSpaceId`，全量替换时还含 `crossSpaceTargets`）— 这条关系要求两端在同一空间，而它们不在。今天有这条要求的是 `depends` 与 `attaches`（附件）；`aligns` / `ref` / `derived` / `origin` 可以跨空间。
- `404 link_not_found` — 要撤的那条边本来就不存在。
- `404 link_target_not_found`（响应含 `target`）— `ref` / `derived` 的目标不存在或不在可见范围。短号在**宿主任务所在空间**里解析。
- `409 link_from_body`（响应含 `relation` / `target` / `hint`）— 要撤的 `ref` 边来自本任务正文里的短号引用。改正文、删去那处引用。
- `400 self_link` — `ref` / `derived` 指向了本任务自己。
- `400 invalid_stage`（响应含 `validStages`）— `move` 的目标阶段不在该任务的合法阶梯里，从 `validStages` 里挑。
- `409 no_ladder` — 该任务没有阶梯（详情的 `ladder` 为 `null`），换个阶段名重试也不会成立。状态推进改用 start / hold / resume / close。
- `409 not_open` / `not_paused` / `not_closed` — 动词的前置状态不满足（如对已关闭任务 start，或对未暂停任务 resume）。
- `409 retention_expired` — 要恢复的条目已超过回收站保留期，恢复不了（内容随后被彻底清除）。仍需要那些内容就重新创建一条。保留期见 `GET /api/trash` 的 `retentionMs`。
- `409 conflict`（响应含 `current` = 最新状态）— 带 `baseVersion` 的 PATCH 撞上并发编辑。基于 `current` 重算后重试。
- `400 invalid_input`（响应可含 `details`）— body 的某个字段**值**不合法（类型错、枚举写错、日期不是完整 ISO，如 `energy` 写成 `medium`、`prio` 写成 `"high"`、`dueAt` 只给了 `"2026-09-15"`）。字段名不认识则是 `unknown_field`。
- `400 invalid_assignees`（响应含 `unresolved`）— `assigneeIds` 里有不是该空间成员的 userId。合法取值见「负责人」一节。
- `400 personal_space_no_assignees` — 往个人空间的任务/目标写了非空 `assigneeIds`，或往个人空间的任务加负责人。换 userId 没用，见「负责人」一节。
- `404 assignee_not_found`（响应含 `target`）— 要摘的那个人本来就没被指派在这条任务上。先读一遍 `assigneeIds` 对一下。
- `400 too_many_assignees`（响应含 `limit`）— 负责人数超过上限，见「负责人」一节。整份写入请减少 `assigneeIds` 的条目；加一个人请先摘掉一个。
- `400 entity_required` — 读评论没锚定宿主。补 `?entityType=task|note|goal&entityId=…`。
- `400 invalid_parent` — 回复的 `parentId` 不是同一宿主上未删除的评论（跨宿主/跨空间的 id、已被删的父评论都算）。先读一遍讨论拿到活的评论 id，或直接发根评论。
- `400 ai_kind_forbidden` — 令牌试图发 `kind:"ai"` 的评论。去掉这个字段即可，令牌一律以持有人身份发 `user` 评论。
- `400 use_body_markdown`（响应含 `field`）— 令牌直发了 `bodyHtml`/`bodyJson`（或知识的 `contentHtml`/`contentJson`）。正文只走 `bodyMarkdown`/`bodyText`。
- `400 lossy_overwrite_needs_force`（响应含 `lossyNodes`）— 要整篇覆盖的那份正文含 markdown 表达不了的内容。确认后带 `"force": true` 重试。
- `400 body_required` — 发评论时没有可用正文：只给了 `html`/`json`、正文字段全缺、或给的是空白。改用 `bodyMarkdown` 或 `bodyText`。只判创建，`PATCH` 不要求带正文。
- `400 invalid_space` — `?space=` 的值不适用于本端点：寻址端点（单实体、评论、流转事件）收到了 `all`，而跨空间只有列表与搜索支持。改给一个具体空间。
- `404 space_not_found` — `?space=` 的值解析不到任何你所属的空间。拼错的 slug **不会**被静默忽略成「全部空间」——用 `whoami` 对一下 `spaces`。**列表、搜索与寻址（含 uuid 寻址）都这么报**。
- `400 ambiguous_seq`（响应含 `candidates`，每条带 `shortId` / `spaceId` / `title`；超过 20 条另附 `candidatesTotal` = 总数）— URL 里的短号（或评论的 `entityId`）在你够得着的多个空间里都有同号实体。挑一条候选，按它的 `spaceId` 补 `?space=`，或改用实体 `id`。
- `404 not_found` — 实体不存在或不在可见范围内。**用短号时，是你够得着的空间里一个都没有这个号**——它可能属于访问集之外的空间，或类型字母与端点不符。
- `404 entity_not_found` — 评论端点的宿主实体查不到：短号/uuid 不对、类型字母与 `entityType` 不符、或宿主在令牌访问集之外（见「越界报 403 还是 404」）。
- `429 abuse_limit_exceeded`（响应含 `scope`）— 按 `scope` 分：
  - `search` — 搜索过于频繁，稍候重试。
  - `recall` — 召回过于频繁，稍候重试。见「相关」一节。
  - `token` — 这枚令牌的调用总量超限，与打的是哪个端点无关。按响应头 `Retry-After`（秒；CLI 印在错误输出里）等待后再发，不要立即重试；持续撞上说明循环调用过密，要降频。

## 用 CLI（推荐）

```bash
node sotogoal.mjs list task                      # 瘦列表(无正文);落默认空间,响应的 space 字段标明是哪个
node sotogoal.mjs list task --limit 20           # 这一页要几条(缺省 100,上限 500)
node sotogoal.mjs list task --cursor "$(node sotogoal.mjs list task | jq -r .nextCursor)"  # 下一页:原值回填;hasMore 为 false 才算看完
node sotogoal.mjs list task --space gewu         # 换个空间(slug/id/me)
node sotogoal.mjs list task --space all          # 跨全部可见空间(显式)
node sotogoal.mjs list task --space all --seq 413  # 按短号查找:用户丢来 #T413 但不知哪个空间,用这条拿候选
node sotogoal.mjs list task --seq T413,T415      # 收 413/T413/#T413,逗号分隔一次查多个
node sotogoal.mjs list task --goal G25,G26       # 按目标筛(收 25/G25/uuid,逗号分隔多个);**不递归**子目标,要整棵树把父子一起给
node sotogoal.mjs list task --tags 更新日志,已发布  # 按标签筛(task/note 都认);多值是 AND —— 两个都挂着才回
node sotogoal.mjs list task --space me --archived  # 盘点要两趟:默认一趟 + --archived 一趟才是全集
node sotogoal.mjs list task --gravity            # 引力排序:「接下来做什么」用这个(缺省序是最近更新在前,不表示优先级)
node sotogoal.mjs get task 203 --space flow      # 短号 + 空间；响应含 ladder
node sotogoal.mjs create task --title "写周报" --prio 2 --kind chore --domain ops --energy low   # 分类与工作量随手给全,别留 null
node sotogoal.mjs create task --title "登录偶发 500" --kind bug --domain tech --json '{"estimateDays":2}'  # 知道量级就给 estimateDays(覆盖 energy 折算)
node sotogoal.mjs create task --title "交付验收报告" --kind doc --json '{"dueAt":"2026-09-15T15:59:00.000Z"}'  # 用户提了期限就设 dueAt(完整 ISO;当地 23:59 折算)
node sotogoal.mjs create note --title "复盘纪要" --json '{"bodyMarkdown":"## 结论\n\n- 要点","tagNames":["复盘"]}'  # 结构化正文写 markdown;短句用 bodyText
node sotogoal.mjs create goal --title "Q4 激活率提升" --parentId <父目标 uuid> --priority key   # 目标的字段与任务不同(priority 不是 prio、deadline 不是 dueAt);不给 --parentId 即顶层目标,在团队空间里要管理员
node sotogoal.mjs create goal --title "月活破万" --deadline 2026-12-31T15:59:00.000Z  # 目标的量化靠 KR,见 kr add
node sotogoal.mjs create task --title "跟进客户" --json '{"tagNames":["销售","P0"]}'   # 标签只在**创建时**能整批给
node sotogoal.mjs update task <uuid> --json '{"prio":1}'                  # 改普通字段(带 tagNames 会被本地拦下)

# —— 改正文:先读旗,回写带 baseVersion ——
node sotogoal.mjs get task T203 | jq '.task|{version,bodyMarkdownLossy,bodyMarkdownLossyNodes}'  # lossy=false 才适合整篇改写
node sotogoal.mjs update task T203 --json '{"bodyMarkdown":"## 结论\n\n改过的全文","baseVersion":7}'
node sotogoal.mjs update task T203 --json '{"bodyMarkdown":"…","baseVersion":7,"force":true}'    # lossy 文档确认覆盖
# —— 正文历史(只读):列表拿 id,再取那一版的正文 ——
node sotogoal.mjs revisions T203                 # 新到旧;不含正文;locked=true 即超出可回溯天数
node sotogoal.mjs revision <revisionId>          # 那一版的正文(revisionId 来自上一条)
node sotogoal.mjs revision <revisionId> | jq -r '.revision.bodyMarkdown'   # 退回上一版:取出来再 update 写回去
# —— 标签:创建时可整批给,之后只能单条增删 ——
node sotogoal.mjs tags                           # 打标签前先看已有标签,复用规范名
node sotogoal.mjs tag add T203 P0                # 挂一条(幂等;名字不存在则新建)
node sotogoal.mjs tag add N3 复盘                 # 知识同构(短号字母即宿主类型;目标没有标签轴)
node sotogoal.mjs tag rm  T203 P0                # 摘一条(只需 write;没挂过 → 404)
# —— 负责人:加/摘某一个人用这两条,别整份回写 ——
node sotogoal.mjs assignee add T203 me           # 派给自己(幂等;已在里面即 no-op)
node sotogoal.mjs assignee add T203 <userId>     # 派给别人(userId 取自已有 assigneeIds 或事件的 actorId)
node sotogoal.mjs assignee rm  T203 me           # 摘掉自己(只需 write;没指派过 → 404)
node sotogoal.mjs update task T203 --json '{"assigneeIds":["me"]}'   # 整份替换:整组就该是这几个人时才用
node sotogoal.mjs delete task <uuid>
# —— 回收站:软删的另一端(保留期内拿回来) ——
node sotogoal.mjs trash                          # 还在保留期内的软删条目(三类合并);先看响应的 types 是不是三类都在
node sotogoal.mjs trash --space all --type task  # 跨空间只看任务("我刚才在哪个空间删的来着")
node sotogoal.mjs restore T203                   # 恢复一条:短号字母即类型,只要 write 档
node sotogoal.mjs restore <uuid> --type goal     # 裸数字/uuid 默认 task,恢复别的两类要显式给

# —— 评论/讨论 ——
node sotogoal.mjs get task T203                  # 一页讨论已内联在响应的 comments 里
node sotogoal.mjs comments T203                  # hasMore 为 true 或 comments 键不在才要这一趟
node sotogoal.mjs comments N3 --limit 20         # 短号带字母即自带宿主类型(T/N/G),不必再写 --type
node sotogoal.mjs comments T203 --limit 200 --cursor <nextCursor>   # 接着往更旧翻,直到 hasMore 为 false
node sotogoal.mjs comments <uuid> --type goal    # uuid / 裸数字推不出类型,默认 task,要别的就显式给
node sotogoal.mjs comment T203 --md '## 复核结论

- 口径改为按周统计,原正文里的按月作废
- 依据见 #N3'                                   # 发一条评论:作者=持有人,另带「经 <本令牌 via>」标记
node sotogoal.mjs comment T203 --text "已按新口径重跑,数字对上了" --parent <commentId>   # 回复某条
node sotogoal.mjs comment edit <commentId> --text "口径写错了,应为按周"   # 改自己发过的那条(整条替换)
node sotogoal.mjs comment T203 --md - <<'EOF'                    # 长正文从 stdin 读,见下面「长正文写 -」
## 复核结论
- 口径改为按周统计
EOF

# —— 任务状态与阶段:一律用生命周期动词 ——
node sotogoal.mjs start  <id>                        # 开始处理 → active
node sotogoal.mjs hold   <id> --onHoldReason "等设计" # 暂停
node sotogoal.mjs resume <id>                         # 恢复
node sotogoal.mjs stages <id>                         # 看该任务合法阶段(= ladder)
node sotogoal.mjs move   <id> --stage "开发中"        # 推进到某阶段;该任务无人负责时顺带落上持有人
node sotogoal.mjs close  <id> --resolution completed  # 关闭(完成);也可 dropped
node sotogoal.mjs close  <id> --resolution duplicate --duplicateOfId <canonicalId>
node sotogoal.mjs reopen <id>                         # 重开

# —— 任务依赖:创建时内联,之后只能单条增删 ——
node sotogoal.mjs create task --title "联调" --depends-on T361,T358   # 仅创建时可用;逗号分隔,展开成 links
node sotogoal.mjs links T400                         # 看全部关联(双向;= 详情 GET 的 links)
node sotogoal.mjs links N3                           # 知识那头:谁引用了它(dir:in)
node sotogoal.mjs link add T400 depends T361         # 加一条:T400 ─依赖→ T361(两端都只能是任务)
node sotogoal.mjs link rm  T400 depends T361         # 撤一条(与 add 同档,tasks:write 即可)

# —— 目标 KR:颗粒化单条,别整份替换 ——
node sotogoal.mjs kr add <goalId> --title "月活破万" --target 10000   # 追加 KR
node sotogoal.mjs kr set <goalId> <krId> --current 5200               # 只更 current
node sotogoal.mjs kr rm  <goalId> <krId>                              # 删单条

# —— 日报/周报聚合 ——
node sotogoal.mjs list task --done-after 2026-07-20 --done-before 2026-07-27   # 本周完成(completed)
node sotogoal.mjs list task --created-after 2026-07-22                          # 昨日以来新建
node sotogoal.mjs events --space flow --after 2026-07-20                        # 空间本周流转事件(吞吐/瓶颈);要的就是默认空间时可省 --space
node sotogoal.mjs events --actor me --after 2026-07-20                          # 只看我触发的流转("我"=关键词 me)
node sotogoal.mjs events --task <uuid>                                          # 单任务时间线

node sotogoal.mjs search "新用户上手的摩擦点"                   # 语义搜索(默认知识)
node sotogoal.mjs search "本周待办" --in tasks --limit 5       # 搜任务
node sotogoal.mjs search "竞品调研" --in tasks,notes,goals     # 逗号分隔搜多类,结果按类分组
node sotogoal.mjs search "定价策略" --min-score 0.35           # 只回余弦≥0.35 的强相关
```

几条贯穿所有子命令的约定（完整旗标清单跑 `sotogoal.mjs --help`）：

- **所有 `<id>` 都接受 uuid 与短号**（写 `T203` 或裸 `203`，不带 `#`）。带类型字母的短号自带宿主类型，所以 `comments` / `tag` / `revisions` 可省 `--type`。唯一的例外是 `revision <revisionId>`：它收的是历史列表里那个 `id`，不是实体短号。
- **两个空间旗标对应 API 的两层**：`--space` 是**寻址**参数（走 query），`--spaceId` 是创建时的**空间字段**（进 body）。个人空间两处都写 `me`。
  - 两者收的**值域不同**，别照着一个写另一个：`--space` / `?space=` 收 **slug 或 id**（寻址参数，服务端解析）；`--spaceId` / body 的 `spaceId` **只收 id**（实体字段，原样落库），唯一的例外是保留字 `me`。给 `spaceId` 一个 slug 会得到 `403 forbidden`——那个码说的是「你没有这个空间的权限」，与真实原因（值域不对）不相符，照它去查权限只会越查越远。slug→id 的对照见 `/api/me` 的 `spaces`。
- **`--<字段> 值` 一律原样进 body**，也可用 `--json '{…}'` 透传完整 body。数值字段（`prio` / `sort` / `estimateDays` / `baseVersion` / `target` / `current`）会转成 number；`null` / `true` / `false` 转成对应字面量，`--dueAt null` 即清空该字段。
- **长正文写 `-`，从 stdin 读**（`0.1.15` 起）。收 `-` 的是 `--md` / `--text` / `--json`，以及 `--bodyMarkdown` / `--bodyText` / `--closeNote` / `--onHoldReason`；**表外的旗标给 `-` 会当场报错**，不会把字面的 `"-"` 写进去。stdin 只读一次（两个旗标都写 `-` 报错），内容按字面收、只剥掉行尾那一个换行，空 stdin 报错（清空正文写 `--md ""`）。⚠️ 这是行为变更：`-` 从前是合法正文；要发一个字面的 `-`，走 stdin。
- **本地守卫**（不白跑一趟服务端）：用 `create`/`update` 直接改状态或阶段、在 `update` 上带 `links` / `tagNames` / `tags` / 整份 `keyResults`、在 `create` 上用 `--space`、把只读的 `comments` 配上 `--md`、给目标短号打标签、要带值的旗标（`--limit` / `--seq` / `--order` …）却没给值、在只收固定旗标的子命令上给了不认识的旗标（如 `list --spaceId`），都会被当场拦下并给出该改成什么。

## 直接 curl（需要自定义、或环境没有 node 时）

CLI 只是便捷层，API 是普通 REST——没有 node 的环境不必安装任何东西，按本文档的端点直接 curl 即可。

**用 `-D /dev/stderr` 把响应头导到 stderr**：正文仍在 stdout、照样能管道给 `jq`，同时每次调用都看得见 `X-Skill-Version` / `X-Api-Semantics`（见「你手上这份是不是旧的」）。

```bash
curl -s -D /dev/stderr "$SOTOGOAL_BASE_URL/api/tasks" \
  -H "Authorization: Bearer $SOTOGOAL_TOKEN"

curl -s -D /dev/stderr -X POST "$SOTOGOAL_BASE_URL/api/tasks" \
  -H "Authorization: Bearer $SOTOGOAL_TOKEN" \
  -H "content-type: application/json" \
  -d '{"title":"写周报","prio":2}'
```
