feat(ticket-management)!: 结构评估先行 —— 四段任务全貌 + 只记要点闸门 + 有效性与整洁性自检

用户 2026-09-24 交办:工单 skill 在**新建 / 更新**时须**优先评估正文 + 评论该有的结构**,
以合适的安排把关键内容记进工单;**不可事无巨细**,只记能组成「任务全貌」的要点
(**问题背景 / 处置提案 / 处置结果 / 效果对比**);skill 须**始终维护所编辑工单的结构有效性与整洁性**。

【本轮整改】
- SKILL.md
  · 新增**闸门 A「先评结构」**(§8.0 五步 + 落点表)与**闸门 B「只记要点」**(§8.2 四问),
    置于 §1 硬规则之首 —— 动工单前的第一动作,不能事后补。
  · 重写 §8.1 正文骨架为**四段任务全貌**;③处置结果 / ④效果对比**未发生不写空壳**。
  · 新增 §8.3 **有效性 + 整洁性自检清单**,并升为 §1 规则 4(可交付条件)。
  · 修 §1 规则 2 与 §2 的自相矛盾(「可选【闸门】」vs「并进同一括号」)⇒ 统一为【域-闸门】。
  · §3.2 补四段段序;§7 写明 verify=有效性 / assess=结构 的分工。
- bin/ticket.mjs
  · 新增 assess(只读结构体检:四段 / 缺段 / 顺序 / 空壳 + 评论清单)
    · update(**标题与正文同一次 PATCH**,过自检才放行)
    · comments(清单 / --dump **删前全量备份**)· comment-add · comment-del(**默认只预览**,--yes 才删)。
  · create 也先打印结构评估;令牌改**延迟读取**(assess --body-file 无需令牌)。
  · 结构判定取「最长词优先」+「同级标题界定空壳」+「更正/补充小节不计顺序」。
- templates/issue.md:改为四段骨架;修 `域=<L1>/<L2>/<L3>` 与 §3.1 / 代码不一致(应为 `-` 分隔)。
- README.md:技能清单说明同步。

【收录此前在途未提交改动】(原属他方;依 86 §13.26 三「待技能属主 / 委托人提交」,本轮一并收束)
- 建单 `--label` 必传 + 断言 7「已带标签」;§1 规则 1b/1c;§8 时间线纪律;§9 实测补充。

【验证 · E1 · 本机】
- `node --check bin/ticket.mjs` 通过。
- `verify 157 158 159 --repo=xbc/xbc` ⇒ **21/21 通过**(七断言未改 ⇒ 向后兼容)。
- `assess --body-file=_body157/158/159.md` ⇒ 段位与顺序判定与三单实际结构相符;
  合成反例(缺必备段 + 乱序 + 空壳)三项全部命中。
- `update 159 --dry-run` 与 `comment-del 534`(仅预览)均未落盘。

【遗留】
- 本仓只改文件不推送时,工作树在 `分析/_tmp/`(可删)⇒ 需推送 `origin/main` 才算持久。
- 接入本工作区(`.dsh/skills/agents-skills` junction)依 86 §12.43 属「待产研负责人决定」。
This commit is contained in:
2026-09-24 10:21:28 +08:00
parent 47226f78be
commit 47f82dfde8
4 changed files with 388 additions and 45 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
| 技能 | 覆盖 | 主要文件 | | 技能 | 覆盖 | 主要文件 |
|---|---|---| |---|---|---|
| `ticket-management` | Gitea 工单的「人读 + 机读」双面管理(标签零新增 · 标题问题域前缀 · 正文首行机读 · 正文自包含 · 来源只进末尾机读引用块) | `SKILL.md` · `bin/ticket.mjs` · `templates/issue.md` | | `ticket-management` | Gitea 工单的「人读 + 机读」双面管理,且**始终维护结构有效性与整洁性**:**动工单前先评结构**(四段任务全貌 = 问题背景 / 处置提案 / 处置结果 / 效果对比 → 出「落点表」)· **只记要点**(改不了全貌的不进工单)· 标题【问题域】(闸门并入同一括号)· 正文首行机读 · 正文自包含 · 来源只进末尾机读引用块 · `verify`(有效性)+ `assess`(结构体检) | `SKILL.md` · `bin/ticket.mjs` · `templates/issue.md` |
## 约定 ## 约定
@@ -23,4 +23,4 @@
New-Item -ItemType Junction -Path '<项目>/.dsh/skills/agents-skills' -Target '<本仓路径>' New-Item -ItemType Junction -Path '<项目>/.dsh/skills/agents-skills' -Target '<本仓路径>'
# 方式二:只取一个技能 # 方式二:只取一个技能
Copy-Item -Recurse '<本仓>/ticket-management' '<项目>/.dsh/skills/' Copy-Item -Recurse '<本仓>/ticket-management' '<项目>/.dsh/skills/'
``` ```
+140 -22
View File
@@ -1,27 +1,36 @@
--- ---
name: ticket-management name: ticket-management
description: 用 Gitea 工单库做「人读 + 机读」双面管理 —— 标签零新增;标题用【问题域】前缀(闸门仅「不能动」时作第二段);正文**首行一行机读字段**(域/闸门/优先级/类型/模式/端/环境/证据);正文**自包含**(禁本地路径与内部分析编号,源码用仓库相对路径 + 该 ref 的 permalink);来源只能进**末尾机读引用块**(须写明 ref-owner)。含 bin/ticket.mjs 的 labels/milestones/list/verify/create/rename。适用于:建单、批量规范化既有工单标题、校验工单是否合规、用 API 读写 Gitea issue。 description: 用 Gitea 工单库做「人读 + 机读」双面管理,并**始终维护工单的结构有效性与整洁性**。**动工单(新建或更新)前先做结构评估**:把本轮信息按「问题背景 / 处置提案 / 处置结果 / 效果对比」四段**任务全貌**出「落点表」,再过「只记要点」四问闸门(改不了全貌的不进工单);正文 = 现行唯一权威叙事,评论只留必须可执行物,外部来源只进末尾机读引用块。含 bin/ticket.mjs 的 labels/milestones/list/verify/assess/create/update/comments/comment-add/comment-del/rename。适用于:建单、更新工单正文或评论、整理精简既有工单、校验工单是否合规、用 API 读写 Gitea issue。
--- ---
# ticket-management(Gitea 工单管理) # ticket-management(Gitea 工单管理)
> **定位**:执行方法(怎么做),不含项目结论。 > **定位**:执行方法(怎么做),不含项目结论。
> **一句话**:**标签给人(少而稳)· 正文块给机器(全而准)· 标题给人扫(问题域 + 能否动)**。 > **一句话**:**动工单前先评结构 · 只把改变「任务全貌」的要点写进去 · 正文是唯一权威叙事 · 每次改完都自检「有效性 + 整洁性」**。
## 0 什么时候用 / 不用 ## 0 什么时候用 / 不用
**用**:建单;批量规范化既有工单的标题 / 首行;校验工单是否符合本规范;用 API 读标签 / 里程碑 / 工单。 **用**:新建工单;**更新**工单(标题 / 正文 / 评论);整理或精简既有工单;校验工单是否合规;用 API 读标签 / 里程碑 / 工单。
**不用**:拿工单做排期与资源计划(那是项目管理);把工作区内部知识库路径写进工单(见 §1 硬规则 3)。 **不用**:拿工单做排期与资源计划(那是项目管理);把工作区内部知识库路径写进工单(见 §1 规则 3)。
## 1 三条硬规则(不合规就不建单) ## 1 硬规则(不合规就不建单 / 不改单)
1. **标签零新增** —— 只用仓库既有标签;**不得**为分类新建标签(标签是给人扫的稀缺资源,词表一膨胀人就不再用了)。 > **动工单(新建或更新)前必过两条前置闸门** —— 它们是本技能的**第一动作**,不能事后补:
2. **标题 = `【问题域】` + 可选 `【闸门】`** —— 闸门**只在「当前不能开工」时**出现;能开工就不加(默认即常态,零打扰)。 > - **闸门 A · 先评结构**(§8.0):先出**落点表**(本轮每条信息落在**正文哪一段 / 评论 / 外部**),再落笔。**没有落点表,不许改工单。**
> - **闸门 B · 只记要点**(§8.2):每条候选信息过**四问** —— **改变不了「任务全貌」的,一律不进工单**(工单不是日志)。
>
> **改完必过第三条**:**§8.3 自检 + `verify`(§7)全过**,才算改完(见规则 4)。
1. **标签零新增 · 且建单必传** —— 只用仓库既有标签;**不得**为分类新建标签(标签是给人扫的稀缺资源,词表一膨胀人就不再用了);**建单 `--label=<id>` 为必传参数**,缺失或 id 无效 ⇒ 工具**拒建**(实测 `#156`/`#157` 因未传而裸建无标签,用户 2026-09-23 纠正)。
1b. **问题与解决方案一律落工单 · 但落点只有一个**(用户 2026-09-23 交办,**2026-09-24 修订**):凡与**问题**或**解决方案**相关的产出(现象 / 根因 / 证据 / 方案与选项 / 执行窗口与风险 / 回退 / 验收),都必须**有工单落点**;但**落点 = 该单的「问题正文」**(见 §8 结构纪律),**不是评论流水**。文档可以详写,工单只写**现行叙事 + 证据指针**(指针进末尾引用块),且单号与外部记录号互指。
1c. **工单 = 干净时间线(rebase 模型 · 2026-09-24 立 · 用户交办)**:「工单能溯源」指的是**类似 git 的 `rebase`**——**不是** `merge`,**不是**把每个变更都堆上去。要求:**该删删、该合并合并**,只留能读懂的**问题 → 行动 → 现状 → 判据**。**执行细则见 §8。**
2. **标题 = `【问题域】`**(当前**不能开工**时,把闸门**并进同一个括号**写成 `【域-闸门】`,分隔符是**半角连字符 `-`**;**不要写两个括号**)—— 闸门**只在「当前不能开工」时**出现;能开工就不加(默认即常态,零打扰)。
3. **正文自包含** —— 人读部分**不得**出现工作区 / 本地路径(`codes/`、`分析/`、`C:\…`、`/home/…`)或内部分析编号;源码引用一律 **仓库相对路径 + 该 ref 的 permalink**。 3. **正文自包含** —— 人读部分**不得**出现工作区 / 本地路径(`codes/`、`分析/`、`C:\…`、`/home/…`)或内部分析编号;源码引用一律 **仓库相对路径 + 该 ref 的 permalink**。
4. **结构有效性与整洁性是可交付条件** —— 每次编辑后**必须**跑 §8.3 自检与 `verify`(§7)并**全过**;**结构坏了就不算改完**(宁可先不改)。
## 2 标题规范 ## 2 标题规范
- **前缀(必填,单括号、`/` 分隔)**:`【订单】` `【库存】` `【结算】` `【商品】` `【报表】` `【平台】` `【其他】`;带闸门时**合并成一个括号**:`【域-闸门】`(例 `【报表-等口径】`)—— 分隔符用**半角连字符 `-`**;**不要写两个括号**(`【报表】【等口径】` 已废止)。 - **前缀(必填,单括号、`-` 分隔)**:`【订单】` `【库存】` `【结算】` `【商品】` `【报表】` `【平台】` `【其他】`;带闸门时**合并成一个括号**:`【域-闸门】`(例 `【报表-等口径】`)—— 分隔符用**半角连字符 `-`**;**不要写两个括号**(`【报表】【等口径】` 已废止)。
- 词表**可按项目替换**,但**改动须登记**;新域先用 `【其他】`,**同域攒够 3 条再立**新前缀。 - 词表**可按项目替换**,但**改动须登记**;新域先用 `【其他】`,**同域攒够 3 条再立**新前缀。
- **闸门(可选)**:`-等口径` `-等取证` `-等部署` —— **仅当「不能开工」**;可开工 ⇒ 括号里只有域。 - **闸门(可选)**:`-等口径` `-等取证` `-等部署` —— **仅当「不能开工」**;可开工 ⇒ 括号里只有域。
- **准入三问**(新增任何段都要过):① 列表页会按它筛 / 排吗?② 能否从正文推出(能推就别重复占位)?③ 生命周期里会变几次(反复变 = 每次都要改标题)? - **准入三问**(新增任何段都要过):① 列表页会按它筛 / 排吗?② 能否从正文推出(能推就别重复占位)?③ 生命周期里会变几次(反复变 = 每次都要改标题)?
@@ -36,11 +45,18 @@ description: 用 Gitea 工单库做「人读 + 机读」双面管理 —— 标
- **字段顺序固定**;分隔符 = 空格 + 竖线 + 空格。 - **字段顺序固定**;分隔符 = 空格 + 竖线 + 空格。
- **`域` 的 L1 必须等于标题主前缀**(机器可校验);`域` 三段逐级更细、**用半角连字符 `-` 分隔**(`域=L1-L2-L3`,如 `域=报表-统计-模式分列`):**L1 受控**(= 标题词表)· **L2 受控小词表** · **L3 自由**(表 / 文件 / 函数 / 接口)。 - **`域` 的 L1 必须等于标题主前缀**(机器可校验);`域` 三段逐级更细、**用半角连字符 `-` 分隔**(`域=L1-L2-L3`,如 `域=报表-统计-模式分列`):**L1 受控**(= 标题词表)· **L2 受控小词表** · **L3 自由**(表 / 文件 / 函数 / 接口)。
- 枚举建议:`闸门`=可开工|等口径|等取证|等部署 · `优先级`=立即|本迭代|排队 · `类型`=安全|数据|性能|可用|债务 · `端`=后端|前端|小程序|运维 · `环境`=正式服|测服 · `证据`=E1|E2|需生产取证。 - 枚举建议:`闸门`=可开工|等口径|等取证|等部署 · `优先级`=立即|本迭代|排队 · `类型`=安全|数据|性能|可用|债务 · `端`=后端|前端|小程序|运维 · `环境`=正式服|测服 · `证据`=E1|E2|需生产取证。
- 若标题出现第二段,**必须与 `闸门=` 一致**。 - 若标题出现闸门段,**必须与 `闸门=` 一致**。
### 3.2 正文其余(人读) ### 3.2 正文其余(人读)= 「任务全貌」四段
现象 → 证据锚点(仓库路径 + permalink)→ 复现(只读命令,仓库内执行)→ 影响面 → 反证条件 ## 问题背景 现象 · 影响面 · 根因(含证据锚点)· 复现
## 处置提案 打算怎么做 / 已选路线 / 为什么;执行窗口与风险;回退
## 处置结果 实际做了什么 · 当前状态 ← 未发生就整段不写
## 效果对比 改前 vs 改后:指标 + 口径 + 时点 ← 未测就整段不写
(可选补段)## 验收判据 / ## 反证条件 / ## 遗留
- **段序固定**(背景 → 提案 → 结果 → 效果);**未发生的段不写空壳**(宁缺不空);**判据 / 反证 / 遗留**放四段之后。
- 证据锚点 = 仓库相对路径:行 + permalink(§4)。**取舍与粒度见 §8.1 / §8.2。**
### 3.3 末尾 = 机读引用块(人读渲染不可见) ### 3.3 末尾 = 机读引用块(人读渲染不可见)
@@ -69,19 +85,30 @@ refs:
```bash ```bash
# 令牌:GITEA_TOKEN 环境变量,或 --token-file=<含 key=value 的文件>(不落盘、不回显) # 令牌:GITEA_TOKEN 环境变量,或 --token-file=<含 key=value 的文件>(不落盘、不回显)
node bin/ticket.mjs labels --repo xbc/xbc node bin/ticket.mjs labels --repo=xbc/xbc
node bin/ticket.mjs milestones --repo xbc/xbc node bin/ticket.mjs milestones --repo=xbc/xbc
node bin/ticket.mjs list --repo xbc/xbc --state=open node bin/ticket.mjs list --repo=xbc/xbc --state=open
node bin/ticket.mjs verify 142 --repo xbc/xbc node bin/ticket.mjs verify 142 --repo=xbc/xbc # 七项断言
node bin/ticket.mjs create --repo xbc/xbc --title='【平台】…' --body-file=body.md --label=77 [--dry-run] node bin/ticket.mjs assess 142 --repo=xbc/xbc # 结构评估(落点表底稿)
node bin/ticket.mjs rename 142 --repo xbc/xbc --domain=平台 [--gate=等口径] [--dry-run] node bin/ticket.mjs assess --body-file=body.md # 只评正文(无需令牌)
node bin/ticket.mjs create --repo=xbc/xbc --title='【平台】…' --body-file=body.md --label=77 [--dry-run]
node bin/ticket.mjs update 142 --repo=xbc/xbc --body-file=body.md [--title='【平台】…'] [--dry-run]
node bin/ticket.mjs comments 142 --repo=xbc/xbc [--dump=_备份.md] # 清单 / 删前备份
node bin/ticket.mjs comment-add 142 --repo=xbc/xbc --body-file=cmt.md [--dry-run]
node bin/ticket.mjs comment-del 12345 --repo=xbc/xbc [--yes] # 不带 --yes 只预览
node bin/ticket.mjs rename 142 --repo=xbc/xbc --domain=平台 [--gate=等口径] [--dry-run]
``` ```
| 子命令 | 作用 | 需要 scope | | 子命令 | 作用 | 需要 scope |
|---|---|---| |---|---|---|
| `labels` / `milestones` / `list` | 读(标签 id 是创建时唯一可传的形式) | `read:issue` | | `labels` / `milestones` / `list` | 读(标签 id 是创建时唯一可传的形式) | `read:issue` |
| `verify <n>` | **校验**(只读):见 §7 六项断言 | `read:issue` | | `verify <n>` | **校验**(只读):见 §7 七项断言(含标签非空) | `read:issue` |
| `create` | 建单(**先按标题查重**) | `write:issue` | | `assess <n>` / `assess --body-file=f` | **结构评估**(只读):四段现状 / 缺段 / 空壳段 + 评论清单 ⇒ §8.0 落点表底稿 | `read:issue` |
| `create` | 建单(**先按标题查重**;**`--label` 必传**,缺失 / id 不存在 ⇒ 拒建) | `write:issue` |
| `update <n>` | 改正文(**标题与正文同一次 PATCH**,§8.1 第 4 条);过自检,不过 ⇒ 拒改 | `write:issue` |
| `comments <n>` | 评论清单(id / 字数 / 首行);`--dump=f` = 全量备份(**删前必做**) | `read:issue` |
| `comment-add <n>` | 加评论(只放**必须可执行物**,§8.1 第 2 条) | `write:issue` |
| `comment-del <id>` | 删评论(**默认只预览**;`--yes` 才删 ⇒ 与 `--dump` 备份配套) | `write:issue` |
| `rename <n>` | 规范化标题(重建前缀,描述不动) | `write:issue` | | `rename <n>` | 规范化标题(重建前缀,描述不动) | `write:issue` |
## 6 常见坑(实测) ## 6 常见坑(实测)
@@ -90,16 +117,107 @@ node bin/ticket.mjs rename 142 --repo xbc/xbc --domain=平台 [--gate=等口径]
- **建单前必须查重**:按标题查,否则会重复建单。 - **建单前必须查重**:按标题查,否则会重复建单。
- **标签传 id**:`POST /issues` 的 `labels` 是**标签 id 数组**,不是名字。 - **标签传 id**:`POST /issues` 的 `labels` 是**标签 id 数组**,不是名字。
- **批量改首行时必须保持 ` | ` 形态**(空格 + 竖线 + 空格):把 `域=…` 换成新值时若顺手吃掉它后面的空格,`闸门=` 会与 `域` 黏成一段 ⇒ **断言 4 全线 FAIL**(实测 14/14 中招,工具拦住了)。 - **批量改首行时必须保持 ` | ` 形态**(空格 + 竖线 + 空格):把 `域=…` 换成新值时若顺手吃掉它后面的空格,`闸门=` 会与 `域` 黏成一段 ⇒ **断言 4 全线 FAIL**(实测 14/14 中招,工具拦住了)。
- **改标题与正文**:都是 `PATCH /repos/{owner}/{repo}/issues/{n}`。 - **改标题与正文**:都是 `PATCH /repos/{owner}/{repo}/issues/{n}`;**要同一次提交**(用 `update`,见 §8.1 第 4 条)。
- **令牌 scope**:只读 `read:issue,read:repository` 建不了单(403,报错会写 required scope);建 / 改需 `write:issue`;`GET /user` 另需 `read:user`。 - **令牌 scope**:只读 `read:issue,read:repository` 建不了单(403,报错会写 required scope);建 / 改需 `write:issue`;`GET /user` 另需 `read:user`。
- **里程碑可能已被当「状态桶」用**(如「记录工单 / 领取工单 / 完结工单」)⇒ 不要再用它承载闸门,否则两套状态打架;用前先 `milestones` 看语义与 `due_on`。 - **里程碑可能已被当「状态桶」用**(如「记录工单 / 领取工单 / 完结工单」)⇒ 不要再用它承载闸门,否则两套状态打架;用前先 `milestones` 看语义与 `due_on`。
- **没有看板时**(`/projects` 返回 404)不要设计「用看板列承载闸门」的流程。 - **没有看板时**(`/projects` 返回 404)不要设计「用看板列承载闸门」的流程。
## 7 `verify` 的六项断言(可交机器) ## 7 `verify` 的七项断言(可交机器)
1. 标题以 `【…】` 开头,且内文 ∈ 问题域词表; 1. 标题以 `【…】` 开头,且内文 ∈ 问题域词表;
2. 正文第一行存在且含 `域=`; 2. 正文第一行存在且含 `域=`;
3. **标题主域 = 首行 `域=` 的 L1**(`域` 内以 `-` 分层); 3. **标题主域 = 首行 `域=` 的 L1**(`域` 内以 `-` 分层);
4. 标题 `-` 后的闸门 ∈ 词表 **且** 等于首行 `闸门=`;**可开工时标题不得带 `-闸门`**(多写即 FAIL); 4. 标题 `-` 后的闸门 ∈ 词表 **且** 等于首行 `闸门=`;**可开工时标题不得带 `-闸门`**(多写即 FAIL);
5. 人读部分(第一个 `<!--` 之前)**不含**工作区路径与内部分析编号 —— 判据用**具体前缀**(`codes/` · `分析/数据/` · `分析/D-`…`分析/F-` · `分析/_tmp` · `分析/_archive` · `86 §` · `80 · V` · 盘符路径);⚠ **不要用裸 `分析/`**(会误伤中文词组,如「进货分析/商品维度」); 5. 人读部分(第一个 `<!--` 之前)**不含**工作区路径与内部分析编号 —— 判据用**具体前缀**(`codes/` · `分析/数据/` · `分析/D-`…`分析/F-` · `分析/_tmp` · `分析/_archive` · `86 §` · `80 · V` · 盘符路径);⚠ **不要用裸 `分析/`**(会误伤中文词组,如「进货分析/商品维度」);
6. 正文若含 `<!--` ⇒ 该注释块必须含 `ref-owner:`。 6. 正文若含 `<!--` ⇒ 该注释块必须含 `ref-owner:`。
7. **必须带标签**(`labels` 非空)—— 与建单的 `--label` 必传配套(2026-09-23 加)。
> `verify` 管**有效性**(标题 / 首行 / 闸门 / 自包含 / 引用块 / 标签);**四段结构的体检在 `assess`**(§8.0)—— 两者分工,不要混用。
## 8 工单结构纪律(**先评结构 · rebase 模型** · 2026-09-24 立 · 与 §1 闸门 A/B、规则 1b/1c 配套)
> **一句话**:**动笔前先定「哪句话放哪儿」;正文 = 现行唯一权威叙事;评论 = 只放必须可执行的东西;过程流水进外部记录,工单里只留指针。**
**为什么**:评论流水会把「现行结论」淹掉 —— 旧版清单、已撤回的表述、被更正的数字与现行内容混在一起,读者**分不清主次**,甚至会**照旧版执行**。
### 8.0 先评结构:动工单前的五步(**闸门 A**)
1. **列候选**:把本轮所有产出(结论 / 证据 / 动作 / 命令 / 读数)逐条列成清单,**先不分正文评论**。
2. **定全貌**:对每条问「它改的是四段里的哪一段?」(§8.1)—— 一段都落不上的,交 §8.2 淘汰。
3. **出落点表**(落到临时文件或贴进对话即可,不必提交):
| 候选信息 | 落点 | 理由 |
|---|---|---|
| … | 正文「处置结果」 | 改变了「做没做 / 做成什么样」 |
| … | 正文「效果对比」 | 前后读数 + 口径 + 时点 |
| … | 评论(可执行) | 必须能照着跑 |
| … | 外部记录 + 末尾指针 | 逐次取证流水 / 推导过程 |
4. **落笔**:正文按 §8.1 的**固定顺序**写;评论只写第 3 步判定为「可执行」的整块。
5. **自检**:跑 §8.3 + `verify`(§7)。**没过就不算改完。**
**机器底稿**:`node bin/ticket.mjs assess <n> --repo=…`(或 `assess --body-file=…`)打印**四段现状 / 缺哪段 / 空壳段 / 评论清单** —— 它只报结构,**判断仍由你(agent)下**。
### 8.1 结构性要求(正文骨架 = 「任务全貌」四段)
1. **正文按固定顺序承载四段,取舍取决于「发生了没有」**:
| 段 | 回答的问题 | 典型小标题 |
|---|---|---|
| **① 问题背景**(必备) | 谁 / 什么场景 / 什么问题 · 影响面 · 根因(含证据) | `## 现象` `## 根因` `## 影响面` |
| **② 处置提案**(必备) | 打算怎么做 / 已选哪条路线 / 为什么 | `## 方案` `## 机制` `## 两条修复路线` |
| **③ 处置结果**(按事实) | 实际做了什么 · 当前状态(已执行 / 挂起 / 未动) | `## 执行回执` `## 当前状态` |
| **④ 效果对比**(按事实) | 改前 vs 改后:**指标 + 口径 + 时点**同表对照 | `## 效果对比` `## T0 / T1 对照` |
- ③④ **只在真的做了 / 真的测了之后**才写;**未发生就整段不写**(不留空壳 —— 空壳等于噪音)。
- **可选补段**(一律放四段之后):`## 验收判据` · `## 反证条件` · `## 遗留`。
- 正文必须**自包含**(§1 规则 3)—— 只读正文就能回答:**现在什么状态 / 要做什么 / 怎么验收 / 效果如何**。
2. **评论只保留「必须可执行物」**:可执行 SQL / 命令 / 待跑清单。**其余过程性评论一律删除**(不是「标注过时后留着」)。
⚠ **可执行物不得因「正文已列名字」而删**(2026-09-24 实测事故):DDL / SQL 这类东西,**判定可删的唯一判据是「其全文已被正文或另一条评论完整承载」**;只列了对象名(如索引名清单)**不等于**承载了可执行语句 —— 一旦删除,工单就**没法照着做**了。
⚠ **工单里的 SQL 必须逐条原样**(2026-09-24 实测事故):**禁止**对语句做「拼接 / 去尾 / 换分隔符」式字符串变换(实测把 `, ALGORITHM=INPLACE, LOCK=NONE;` 换成逗号 ⇒ 18 条语句连成一段**无分号的废码**);**回退脚本不得留占位符**(列序 / 唯一性要填实)—— 否则「可执行物」实际**不可执行**。**投递前必须实跑验证**(结构影子库零数据逐条执行,正反向都要过)。
3. **过程流水与逐条取证进外部记录**(分析文档 / 台账底册),工单里**只在末尾引用块登记**(§3.3);人读部分**不得**出现外部路径与编号。
4. **口径更新必须同步正文与标题**:凡结论/数量/清单发生变化(如「24 条」→「23 条」、「删 5」→「删 4」),必须**同一次**改掉**标题 + 正文 + 摘要评论**(`update` 支持标题与正文同提交);**旧口径若仍在被引用(如执行清单),必须显式标注「已废止」并写明正确值**。
5. **删除前必须备份**:删评论前先 `comments <n> --dump=<文件>` **全量导出**(含 `comment id` 与全文),并在交付说明里给路径 —— **删除 ≠ 丢失**。
6. **一次工单一条线程**:同一问题的新证据**追加到正文**,不要为每条证据新开评论;跨单内容**只在一处详写**,其余用 `#n` 交叉引用。
### 8.2 只记要点:四问闸门(**闸门 B**)
> **判据只有一句**:**这条信息改变「任务全貌」的答案吗?** 改不了 ⇒ **不进工单**。
逐条问:
1. **问题背景**:它让人更清楚「谁 / 什么场景 / 为什么坏」吗?
2. **处置提案**:它改变「打算怎么做 / 为什么选它」吗?
3. **处置结果**:它改变「实际做了什么 / 现在什么状态」吗?
4. **效果对比**:它给出「前后对照的指标 / 口径 / 时点」吗?
**四问全否 ⇒ 不进工单**(改放外部记录;需要时只在末尾引用块留指针)。**典型排除物**:
- 逐次取证的**读数流水**(进底册 / 分析文档;正文只留**结论 + 现行读数**);
- 被取代的**旧版清单 / 旧数字 / 已撤回表述**(**删**;确需防误用的,正文只留一句「**已废止,正确值 X**」);
- **探索与试错过程**(试过哪些路、哪条走不通);
- **与工单无关的旁证**(相邻话题 ⇒ 用 `#n` 交叉引用,不复制)。
⚠ **要点 ≠ 短**:结论、**可执行物**、判据与反证条件都属于**要点**,必须**完整**(如「删 22 条」的**全文清单**)。「不事无巨细」约束的是**过程流水**,**不是结论的完整性** —— 把可执行物删成「见某文档」是**另一类破坏**(§8.1 第 2 条两条实测事故)。
### 8.3 结构有效性与整洁性自检(**每次编辑后必跑 · 规则 4**)
**有效性**(结构还在不在):
- [ ] `node bin/ticket.mjs verify <n> --repo=…` **7/7 全过**(§7);
- [ ] `assess` 报告:**必备两段在**(背景 / 提案),出现者**按序**,**无空壳段**;
- [ ] 标题主域 = 首行 `域=` 的 L1;标题闸门 = 首行 `闸门=`;
- [ ] **只读正文**能独立回答:现在什么状态 / 要做什么 / 怎么验收 / 效果如何。
**整洁性**(有没有垃圾):
- [ ] 四段**顺序正确**,**没有空壳段**(标题下没内容 ⇒ 删);
- [ ] 正文**没有**「旧版 / 已废止」并列堆叠 —— 旧值已删,或压成一句「已废止,正确值 X」;
- [ ] 标题 / 正文 / 评论里的**数量与范围一致**(「23 条」不能只改一处);
- [ ] 评论**只剩可执行物**,且**可执行物完整可跑**(是全文,不是对象名);
- [ ] 同一条信息**没有两处详写**(一处详写,其余 `#n` 交叉引用);
- [ ] 首行机读行仍是 ` | ` 形态、字段顺序未被打乱。
## 9 本工具实测补充(2026-09-24)
- **`--repo` 必须用等号形式**:`--repo=xbc/xbc`。写成空格形式(`--repo xbc/xbc`)时,`xbc/xbc` 会被当成**单号**去请求 ⇒ 报 `读取失败 status=404` 且后面紧跟一行 `#xbc/xbc 读取失败` —— 这是**参数错位**,不是权限问题。
- **令牌**:`GITEA_TOKEN` 环境变量,或 `--token-file=<含 `token`/`accessToken` 的 key=value 文件>`(按行匹配 `/token/i`)。缺令牌时报错但**错误信息可能表现为 404**(私有仓隐藏权限差异)⇒ 先查令牌再怀疑工单。`assess --body-file` **只评正文,不需要令牌**。
- **评论的增删改已收进工具**:`comments`(清单 / `--dump` 备份)· `comment-add` · `comment-del`(**默认只预览**,`--yes` 才删)。底层仍是 `POST /issues/{n}/comments` · `PATCH /issues/comments/{id}` · `DELETE /issues/comments/{id}`(204)。
- **改工单正文用 `update`**:它把**标题与正文在同一次 PATCH 提交**(§8.1 第 4 条的机器保证);只想改标题用 `rename`。
- **`assess` 的界限**:它是**只读结构体检**,报告「缺哪段 / 哪段空壳 / 评论几条」,**不替你判断内容对不对**;落点表仍要人来填(§8.0 第 3 步)。
+220 -12
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
// ticket.mjs — Gitea 工单工具(人读 + 机读规范) // ticket.mjs — Gitea 工单工具(人读 + 机读规范 · **结构评估先行**)
// 子命令:labels | milestones | list | verify <n...> | create | rename <n> // 子命令:labels | milestones | list | verify <n...> | assess <n|--body-file=f> | create
// update <n> | comments <n> | comment-add <n> | comment-del <id> | rename <n>
// 令牌:GITEA_TOKEN 环境变量,或 --token-file=<含 key=value 的文件>(不落盘、不回显) // 令牌:GITEA_TOKEN 环境变量,或 --token-file=<含 key=value 的文件>(不落盘、不回显)
// 纪律真源:同目录 ../SKILL.md(§7 verify 七断言 · §8 结构纪律 / assess 四段体检)
import fs from "node:fs"; import fs from "node:fs";
const DOMAINS = ["订单", "库存", "结算", "商品", "报表", "平台", "其他"]; const DOMAINS = ["订单", "库存", "结算", "商品", "报表", "平台", "其他"];
@@ -10,6 +12,15 @@ const NL = String.fromCharCode(10);
const BACKSLASH = String.fromCharCode(92); const BACKSLASH = String.fromCharCode(92);
const BAD = ["codes/", "分析/数据/", "分析/D-", "分析/E-", "分析/A-", "分析/B-", "分析/C-", "分析/F-", "分析/_tmp", "分析/_archive", "分析/README", "86 §", "80 · V", "/home/", "C:" + BACKSLASH]; const BAD = ["codes/", "分析/数据/", "分析/D-", "分析/E-", "分析/A-", "分析/B-", "分析/C-", "分析/F-", "分析/_tmp", "分析/_archive", "分析/README", "86 §", "80 · V", "/home/", "C:" + BACKSLASH];
// §8.1「任务全貌」四段:正文骨架词表(数组顺序 = 规范顺序)
const SECTIONS = [
{ key: "问题背景", words: ["现象", "根因", "影响面", "问题背景", "背景"], need: "必备" },
{ key: "处置提案", words: ["处置提案", "方案", "机制", "提案", "路线"], need: "必备" },
{ key: "处置结果", words: ["处置结果", "执行回执", "当前状态", "回执", "结果"], need: "按事实" },
{ key: "效果对比", words: ["效果对比", "收益验证", "收益", "对照", "效果"], need: "按事实" }
];
const OPTIONAL_SECTIONS = ["验收判据", "反证条件", "遗留"];
function parseArgs(argv) { function parseArgs(argv) {
const pos = [], opt = {}; const pos = [], opt = {};
for (const a of argv) { for (const a of argv) {
@@ -29,8 +40,9 @@ function readToken(opt) {
} }
function mkApi(opt) { function mkApi(opt) {
const host = opt.host || "code.hoteas.com"; const host = opt.host || "code.hoteas.com";
const H = { Authorization: "token " + readToken(opt), "Content-Type": "application/json", Accept: "application/json", "User-Agent": "agents-skills/ticket-management" }; let H = null; // 延迟取令牌 ⇒ assess --body-file 无需令牌
return async function api(path, method, body) { return async function api(path, method, body) {
if (!H) H = { Authorization: "token " + readToken(opt), "Content-Type": "application/json", Accept: "application/json", "User-Agent": "agents-skills/ticket-management" };
const r = await fetch("https://" + host + "/api/v1" + path, { method: method || "GET", headers: H, body: body ? JSON.stringify(body) : undefined }); const r = await fetch("https://" + host + "/api/v1" + path, { method: method || "GET", headers: H, body: body ? JSON.stringify(body) : undefined });
const t = await r.text(); const t = await r.text();
let j = null; try { j = JSON.parse(t); } catch (e) {} let j = null; try { j = JSON.parse(t); } catch (e) {}
@@ -49,7 +61,7 @@ function fieldOf(line, key) {
return ""; return "";
} }
function humanPart(body) { const b = String(body || ""); const i = b.indexOf("<!--"); return i < 0 ? b : b.slice(0, i); } function humanPart(body) { const b = String(body || ""); const i = b.indexOf("<!--"); return i < 0 ? b : b.slice(0, i); }
// 六项断言(verify 与 create 共用) // 七项断言(verify 与 create/update 共用)
function checksOf(title, body) { function checksOf(title, body) {
const sp = splitTitle(title), L = firstLine(body); const sp = splitTitle(title), L = firstLine(body);
const dom = fieldOf(L, "域"), gate = fieldOf(L, "闸门"), hp = humanPart(body); const dom = fieldOf(L, "域"), gate = fieldOf(L, "闸门"), hp = humanPart(body);
@@ -62,6 +74,70 @@ function checksOf(title, body) {
["6 有引用块 ⇒ 有 ref-owner", body.indexOf("<!--") < 0 ? true : body.indexOf("ref-owner:") >= 0] ["6 有引用块 ⇒ 有 ref-owner", body.indexOf("<!--") < 0 ? true : body.indexOf("ref-owner:") >= 0]
]; ];
} }
// —— §8.0 结构评估(只读)——
function headingsOf(body) {
const out = [];
for (const line of humanPart(body).split(NL)) {
const m = /^(#{1,6})\s+(.*)$/.exec(line);
if (m) out.push({ level: m[1].length, text: m[2].trim() });
}
return out;
}
function sectionIndex(text) {
// 最长词优先(更具体者胜):如「变更后收益验证(…方案)」应归「效果对比」而非「处置提案」
let best = -1, bestLen = 0;
for (let i = 0; i < SECTIONS.length; i++) {
for (const w of SECTIONS[i].words) {
if (text.indexOf(w) >= 0 && w.length > bestLen) { best = i; bestLen = w.length; }
}
}
return best;
}
function isPostNote(text) { return /更正|订正|勘误|补充|追加/.test(text); }
function isOptional(text) { return OPTIONAL_SECTIONS.some(function (w) { return text.indexOf(w) >= 0; }); }
function structureOf(body) {
const lines = humanPart(body).split(NL);
const heads = [];
lines.forEach(function (l, i) {
const m = /^(#{1,6})\s+(.*)$/.exec(l);
if (m) heads.push({ pos: i, level: m[1].length, text: m[2].trim() });
});
// 空壳判定:某标题之后、下一个「同级或更高级」标题之前无任何内容(含子标题)⇒ 空壳
const empty = [];
for (let k = 0; k < heads.length; k++) {
let j = k + 1;
while (j < heads.length && heads[j].level > heads[k].level) j++;
const inner = lines.slice(heads[k].pos + 1, j < heads.length ? heads[j].pos : lines.length)
.join(NL).replace(/<!--[\s\S]*?-->/g, "").trim();
if (!inner) empty.push(lines[heads[k].pos].trim());
}
// 归类只看 ## 级;「更正 / 补充」小节允许后置,不计入顺序
const tops = heads.filter(function (h) { return h.level === 2; })
.map(function (h) { return { text: h.text, idx: sectionIndex(h.text), post: isPostNote(h.text) }; });
const main = tops.filter(function (t) { return !t.post && t.idx >= 0; });
const order = main.map(function (t) { return t.idx; });
const ordered = order.every(function (v, i) { return i === 0 || order[i - 1] <= v; });
const hits = tops.filter(function (t) { return t.idx >= 0; });
const postNotes = tops.filter(function (t) { return t.post; }).map(function (t) { return t.text; });
const unclassified = tops.filter(function (t) { return t.idx < 0 && !t.post && !isOptional(t.text); }).map(function (t) { return t.text; });
const optional = OPTIONAL_SECTIONS.filter(function (w) { return heads.some(function (h) { return h.text.indexOf(w) >= 0; }); });
return { hits: hits, ordered: ordered, empty: empty, optional: optional, postNotes: postNotes, unclassified: unclassified };
}
function printStructure(body) {
const st = structureOf(body);
console.log("四段(§8.1 · 顺序 " + SECTIONS.map(function (s) { return s.key; }).join(" → ") + "):");
for (let i = 0; i < SECTIONS.length; i++) {
const own = st.hits.filter(function (x) { return x.idx === i; }).map(function (x) { return "## " + x.text; });
const tail = own.length ? " " + own.join(" · ")
: (SECTIONS[i].need === "必备" ? " ← 未识别到(必备段:确认是否用了同义小标题,或确实缺)" : " ← 未识别到(未发生 / 未测就不写,不要空壳)");
console.log(" " + (own.length ? "[✓]" : "[ ]") + " " + SECTIONS[i].key + "(" + SECTIONS[i].need + ")" + tail);
}
console.log(" 顺序:" + (st.ordered ? "OK" : "⚠ 主叙事未按 背景→提案→结果→效果 排列 ⇒ 调整(更正 / 补充小节不计)"));
if (st.postNotes.length) console.log(" 后置小节(不计顺序):" + st.postNotes.map(function (t) { return "## " + t; }).join(" · "));
if (st.unclassified.length) console.log(" 未归类 ## 小节(人工判断归属):" + st.unclassified.map(function (t) { return "## " + t; }).join(" · "));
console.log(" 空壳段:" + (st.empty.length ? "✗ " + st.empty.join(" · ") + "(删掉或补齐)" : "无"));
console.log(" 可选段:" + (st.optional.length ? st.optional.join(" · ") : "无"));
}
async function findTitle(api, repo, title) { async function findTitle(api, repo, title) {
const r = await api("/repos/" + repo + "/issues?state=all&type=issues&limit=100"); const r = await api("/repos/" + repo + "/issues?state=all&type=issues&limit=100");
return (Array.isArray(r.j) ? r.j : []).filter(function (x) { return String(x.title || "") === title; }); return (Array.isArray(r.j) ? r.j : []).filter(function (x) { return String(x.title || "") === title; });
@@ -96,34 +172,146 @@ async function cmdVerify(api, opt, nums) {
total++; if (!c[1]) fail++; total++; if (!c[1]) fail++;
console.log(" " + (c[1] ? "PASS" : "FAIL") + " " + c[0]); console.log(" " + (c[1] ? "PASS" : "FAIL") + " " + c[0]);
} }
const labNames = (r.j.labels || []).map(function (l) { return l.name; });
total++; if (!labNames.length) fail++;
console.log(" " + (labNames.length ? "PASS" : "FAIL") + " 7 已带标签" + (labNames.length ? " [" + labNames.join(",") + "]" : " (空 ⇒ 建单时未传 --label)"));
} }
console.log("断言 " + (total - fail) + "/" + total + " 通过" + (fail ? " · FAIL " + fail : "")); console.log("断言 " + (total - fail) + "/" + total + " 通过" + (fail ? " · FAIL " + fail : ""));
return fail ? 1 : 0; return fail ? 1 : 0;
} }
async function cmdAssess(api, opt, n) {
let title = "", body = "", comments = null;
if (opt["body-file"]) {
body = fs.readFileSync(opt["body-file"], "utf8");
title = opt.title ? String(opt.title) : "(未给 --title,只评正文)";
} else {
if (!n) { console.log("用法:assess <n> --repo=o/r | assess --body-file=<文件> [--title=…]"); return 1; }
const r = await api("/repos/" + opt.repo + "/issues/" + n);
if (r.s !== 200) { console.log("读取失败 status=" + r.s); return 1; }
title = String(r.j.title || ""); body = String(r.j.body || "");
const rc = await api("/repos/" + opt.repo + "/issues/" + n + "/comments?limit=100");
comments = Array.isArray(rc.j) ? rc.j : [];
}
console.log("== 结构评估(§8.0 落点表底稿 · 只报结构,判断仍由你下)==");
console.log("标题:" + title);
console.log("首行机读:" + (firstLine(body) || "(空)"));
printStructure(body);
if (comments) {
console.log("评论清单(" + comments.length + ")—— 只应剩「必须可执行物」(§8.1 第 2 条):");
for (const c of comments) {
const b = String(c.body || "");
console.log(" " + c.id + " " + b.length + " 字 首行: " + (b.split(NL)[0] || "").slice(0, 90));
}
if (comments.length > 1) console.log(" ⚠ 多条评论 ⇒ 逐条过 §8.2;删前先备份:comments " + n + " --dump=<文件>(§8.1 第 5 条)");
}
console.log("下一步:把本轮候选信息逐条填进 §8.0 的落点表 ⇒ 再 create / update / comment-add。");
return 0;
}
async function cmdCreate(api, opt) { async function cmdCreate(api, opt) {
const title = opt.title; const title = opt.title;
if (!title) { console.log("用法:create --repo=o/r --title=... --body-file=... [--label=<id>] [--dry-run] [--force]"); return 1; } if (!title) { console.log("用法:create --repo=o/r --title=... --body-file=... --label=<id>[,<id>] [--dry-run] [--force]"); return 1; }
const sp = splitTitle(title); const sp = splitTitle(title);
if (!sp.parts.length || DOMAINS.indexOf(sp.parts[0]) < 0) { console.log("拒绝:标题主前缀不合规 —— " + title); return 1; } if (!sp.parts.length || DOMAINS.indexOf(sp.parts[0]) < 0) { console.log("拒绝:标题主前缀不合规 —— " + title); return 1; }
const dup = await findTitle(api, opt.repo, title); const dup = await findTitle(api, opt.repo, title);
if (dup.length) { console.log("拒绝:已存在同名单 #" + dup[0].number + " —— 先查重是硬规则"); return 1; } if (dup.length) { console.log("拒绝:已存在同名单 #" + dup[0].number + " —— 先查重是硬规则"); return 1; }
const bodyFile = opt["body-file"]; const bodyFile = opt["body-file"];
if (!bodyFile) { console.log("用法:create ... --body-file=..."); return 1; } if (!bodyFile) { console.log("用法:create ... --body-file=..."); return 1; }
// 标签必传(2026-09-23 · 用户交办):缺失即拒建,--force 不可跳过(实测 #156/#157 因未传而裸建无标签)
const labelIds = String(opt.label == null ? "" : opt.label).split(",").map(function (x) { return x.trim(); }).filter(Boolean);
if (!labelIds.length) { console.log("拒绝:建单必须带标签 —— 传 --label=<id>[,<id>](标签表:ticket.mjs labels --repo=" + opt.repo + ")"); return 1; }
const badId = labelIds.filter(function (x) { return !/^[0-9]+$/.test(x) || Number(x) <= 0; });
if (badId.length) { console.log("拒绝:--label 只接受正整数 id,收到 " + badId.join(",")); return 1; }
const rl = await api("/repos/" + opt.repo + "/labels?limit=100");
const knownLabels = Array.isArray(rl.j) ? rl.j : [];
const missId = labelIds.filter(function (x) { return !knownLabels.some(function (l) { return String(l.id) === x; }); });
if (knownLabels.length && missId.length) {
console.log("拒绝:标签 id 不存在 —— " + missId.join(",") + ";本仓可选:" + knownLabels.map(function (l) { return l.id + "=" + l.name; }).join(" · "));
return 1;
}
const body = fs.readFileSync(bodyFile, "utf8"); const body = fs.readFileSync(bodyFile, "utf8");
const bad = checksOf(title, body).filter(function (c) { return !c[1]; }); const checks = checksOf(title, body);
const bad = checks.filter(function (c) { return !c[1]; });
console.log("结构评估(§8.0 · 建单前必看):");
printStructure(body);
if (bad.length && !opt.force) { if (bad.length && !opt.force) {
console.log("拒绝:正文/标题未过自检(" + bad.length + " 项)"); console.log("拒绝:正文/标题未过自检(" + bad.length + " 项)");
for (const c of bad) console.log(" FAIL " + c[0]); for (const c of bad) console.log(" FAIL " + c[0]);
console.log("(确认要建就加 --force)"); console.log("(确认要建就加 --force)");
return 1; return 1;
} }
if (opt["dry-run"]) { console.log("[dry-run] 将建单:" + title + " · 正文 " + body.length + " 字 · 自检 " + (6 - bad.length) + "/6"); return 0; } if (opt["dry-run"]) { console.log("[dry-run] 将建单:" + title + " · 正文 " + body.length + " 字 · 标签 " + labelIds.join(",") + " · 自检 " + (checks.length - bad.length) + "/" + checks.length); return 0; }
const payload = { title: title, body: body }; const payload = { title: title, body: body, labels: labelIds.map(Number) };
if (opt.label) payload.labels = [Number(opt.label)];
const r = await api("/repos/" + opt.repo + "/issues", "POST", payload); const r = await api("/repos/" + opt.repo + "/issues", "POST", payload);
console.log("status=" + r.s + (r.s === 201 ? " → #" + r.j.number + " " + r.j.html_url : " " + r.t.slice(0, 200))); console.log("status=" + r.s + (r.s === 201 ? " → #" + r.j.number + " " + r.j.html_url : " " + r.t.slice(0, 200)));
return r.s === 201 ? 0 : 1; return r.s === 201 ? 0 : 1;
} }
async function cmdUpdate(api, opt, n) {
if (!n) { console.log("用法:update <n> --repo=o/r --body-file=<文件> [--title=…] [--dry-run] [--force]"); return 1; }
const bodyFile = opt["body-file"];
if (!bodyFile) { console.log("用法:update <n> --body-file=<文件>(改正文;标题与正文同一次提交,§8.1 第 4 条)"); return 1; }
const cur = await api("/repos/" + opt.repo + "/issues/" + n);
if (cur.s !== 200) { console.log("读取失败 status=" + cur.s); return 1; }
const title = opt.title != null ? String(opt.title) : String(cur.j.title || "");
const body = fs.readFileSync(bodyFile, "utf8");
const checks = checksOf(title, body);
const bad = checks.filter(function (c) { return !c[1]; });
console.log("结构评估(§8.0 · 改单前必看):");
printStructure(body);
if (bad.length && !opt.force) {
console.log("拒绝:正文/标题未过自检(" + bad.length + " 项)");
for (const c of bad) console.log(" FAIL " + c[0]);
console.log("(确认要改就加 --force)");
return 1;
}
const payload = { body: body };
if (opt.title != null) payload.title = title;
if (opt["dry-run"]) { console.log("[dry-run] PATCH #" + n + " · 标题" + (opt.title != null ? "(同批改)" : "(不动)") + " · 正文 " + body.length + " 字 · 自检 " + (checks.length - bad.length) + "/" + checks.length); return 0; }
const r = await api("/repos/" + opt.repo + "/issues/" + n, "PATCH", payload);
console.log("status=" + r.s + (r.j && r.j.html_url ? " → " + r.j.html_url : " " + r.t.slice(0, 200)));
return (r.s === 200 || r.s === 201) ? 0 : 1;
}
async function cmdComments(api, opt, n) {
if (!n) { console.log("用法:comments <n> --repo=o/r [--dump=<文件>]"); return 1; }
const r = await api("/repos/" + opt.repo + "/issues/" + n + "/comments?limit=100");
if (r.s !== 200) { console.log("读取失败 status=" + r.s); return 1; }
const arr = Array.isArray(r.j) ? r.j : [];
if (opt.dump) {
const head = "# 工单 #" + n + " 评论备份(" + new Date().toISOString() + ")" + NL + NL +
"> 删除前全量备份(SKILL §8.1 第 5 条:删除前必须备份 · 删除 ≠ 丢失)。共 " + arr.length + " 条。" + NL;
fs.writeFileSync(opt.dump, head + arr.map(function (c) {
return NL + NL + "## comment " + c.id + "(" + String(c.body || "").length + " 字符)" + NL + NL + String(c.body || "");
}).join(NL), "utf8");
console.log("已备份 " + arr.length + " 条 → " + opt.dump);
}
console.log("#" + n + " 评论 " + arr.length + " 条(只应剩「必须可执行物」):");
for (const c of arr) {
const b = String(c.body || "");
console.log(" " + c.id + " " + b.length + " 字 首行: " + (b.split(NL)[0] || "").slice(0, 90));
}
return 0;
}
async function cmdCommentAdd(api, opt, n) {
if (!n || !opt["body-file"]) { console.log("用法:comment-add <n> --repo=o/r --body-file=<文件> [--dry-run]"); return 1; }
const body = fs.readFileSync(opt["body-file"], "utf8");
if (opt["dry-run"]) { console.log("[dry-run] 将给 #" + n + " 加评论 · " + body.length + " 字"); return 0; }
const r = await api("/repos/" + opt.repo + "/issues/" + n + "/comments", "POST", { body: body });
console.log("status=" + r.s + (r.s === 201 && r.j ? " → comment " + r.j.id : " " + r.t.slice(0, 200)));
return r.s === 201 ? 0 : 1;
}
async function cmdCommentDel(api, opt, id) {
if (!id) { console.log("用法:comment-del <commentId> --repo=o/r [--yes](不带 --yes 只预览)"); return 1; }
const g = await api("/repos/" + opt.repo + "/issues/comments/" + id);
const text = g.s === 200 ? String(g.j.body || "") : "";
console.log("comment " + id + (g.s === 200 ? "(" + text.length + " 字)" : "(读取失败 status=" + g.s + ",继续前请自行确认 id)"));
if (text) console.log(text.split(NL).slice(0, 6).map(function (l) { return " | " + l; }).join(NL));
if (!opt.yes) {
console.log("预览模式(未删除)。确认要删加 --yes;**删前先备份**:comments <n> --dump=<文件>(§8.1 第 5 条)。");
return 0;
}
const r = await api("/repos/" + opt.repo + "/issues/comments/" + id, "DELETE");
console.log("DELETE status=" + r.s);
return (r.s === 204 || r.s === 200) ? 0 : 1;
}
async function cmdRename(api, opt, n) { async function cmdRename(api, opt, n) {
const r = await api("/repos/" + opt.repo + "/issues/" + n); const r = await api("/repos/" + opt.repo + "/issues/" + n);
if (r.s !== 200) { console.log("读取失败 status=" + r.s); return 1; } if (r.s !== 200) { console.log("读取失败 status=" + r.s); return 1; }
@@ -142,16 +330,36 @@ async function cmdRename(api, opt, n) {
return p.s === 201 ? 0 : 1; return p.s === 201 ? 0 : 1;
} }
const USAGE = [
"用法:ticket.mjs <子命令> --repo=owner/name [...]",
" labels | milestones 读标签 / 里程碑",
" list [--state=open|closed|all] 列工单",
" verify <n...> 七项断言(有效性:标题/首行/闸门/自包含/引用块/标签)",
" assess <n> | assess --body-file=<文件> 结构评估(§8.0 落点表底稿:四段现状/缺段/空壳/评论清单)",
" create --title=… --body-file=… --label=<id>[,<id>] [--dry-run] [--force]",
" update <n> --body-file=… [--title=…] [--dry-run] [--force]",
" comments <n> [--dump=<文件>] 评论清单 / 删前全量备份",
" comment-add <n> --body-file=… [--dry-run]",
" comment-del <id> [--yes] 默认只预览",
" rename <n> --domain=<词表值> [--gate=…] [--dry-run]",
"(公共:--token-file=<文件> | GITEA_TOKEN · --host=<默认 code.hoteas.com>)"
].join(NL);
const A = parseArgs(process.argv.slice(2)); const A = parseArgs(process.argv.slice(2));
const cmd = A.pos[0]; const cmd = A.pos[0];
if (!cmd || cmd === "-h" || cmd === "--help") { console.log("用法:ticket.mjs <labels|milestones|list|verify|create|rename> --repo=owner/name [...]"); process.exit(0); } if (!cmd || cmd === "-h" || cmd === "--help") { console.log(USAGE); process.exit(0); }
if (!A.opt.repo) { console.log("缺 --repo=owner/name"); process.exit(1); } if (!A.opt.repo && !(cmd === "assess" && A.opt["body-file"])) { console.log("缺 --repo=owner/name" + NL + USAGE); process.exit(1); }
const api = mkApi(A.opt); const api = mkApi(A.opt);
let code = 0; let code = 0;
if (cmd === "labels" || cmd === "milestones") code = await cmdRead(api, A.opt, cmd); if (cmd === "labels" || cmd === "milestones") code = await cmdRead(api, A.opt, cmd);
else if (cmd === "list") code = await cmdList(api, A.opt); else if (cmd === "list") code = await cmdList(api, A.opt);
else if (cmd === "verify") code = await cmdVerify(api, A.opt, A.pos.slice(1)); else if (cmd === "verify") code = await cmdVerify(api, A.opt, A.pos.slice(1));
else if (cmd === "assess") code = await cmdAssess(api, A.opt, A.pos[1]);
else if (cmd === "create") code = await cmdCreate(api, A.opt); else if (cmd === "create") code = await cmdCreate(api, A.opt);
else if (cmd === "update") code = await cmdUpdate(api, A.opt, A.pos[1]);
else if (cmd === "comments") code = await cmdComments(api, A.opt, A.pos[1]);
else if (cmd === "comment-add") code = await cmdCommentAdd(api, A.opt, A.pos[1]);
else if (cmd === "comment-del") code = await cmdCommentDel(api, A.opt, A.pos[1]);
else if (cmd === "rename") code = await cmdRename(api, A.opt, A.pos[1]); else if (cmd === "rename") code = await cmdRename(api, A.opt, A.pos[1]);
else { console.log("未知子命令:" + cmd); code = 1; } else { console.log("未知子命令:" + cmd + NL + USAGE); code = 1; }
process.exit(code); process.exit(code);
+26 -9
View File
@@ -1,22 +1,39 @@
域=<L1>/<L2>/<L3> | 闸门=<可开工|等口径|等取证|等部署> | 优先级=<立即|本迭代|排队> | 类型=<安全|数据|性能|可用|债务> | 模式=<…|全模式> | 端=<后端|前端|小程序|运维> | 环境=<正式服|测服> | 证据=<E1|E2|需生产取证> 域=<L1>-<L2>-<L3> | 闸门=<可开工|等口径|等取证|等部署> | 优先级=<立即|本迭代|排队> | 类型=<安全|数据|性能|可用|债务> | 模式=<…|全模式> | 端=<后端|前端|小程序|运维> | 环境=<正式服|测服> | 证据=<E1|E2|需生产取证>
**现象**:<一句话说清「谁在什么场景下会看到什么坏结果」> ## 问题背景
**现象**:<谁在什么场景下会看到什么坏结果>
**证据锚点(仓库 <owner>/<repo> @ <sha8>)** **影响面**:<受影响对象 / 计数 / 频率>
- <path>:<line> —— <该行代码 / 配置原文片段>
**根因(含证据)**:<根因判定>
- <仓库相对路径>:<line> —— <该行代码 / 配置原文片段>
<permalink> <permalink>
**复现(只读 · 在仓库根执行)** **复现(只读 · 在仓库根执行)**
- <只读命令> - <只读命令>
**影响面** ## 处置提案
- <受影响对象 / 计数 / 频率> <打算怎么做 / 已选路线 / 为什么;执行窗口与风险;回退>
**反证条件** ## 处置结果
- <出现什么就说明本判定不成立> <!-- 未发生就整段删除(不留空壳) -->
<实际做了什么 · 当前状态>
## 效果对比
<!-- 未测就整段删除(不留空壳) -->
| 指标 | 口径 | 时点 | 改前 | 改后 |
|---|---|---|---|---|
| <…> | <…> | <…> | <…> | <…> |
## 判据与遗留
**验收判据**:<通过 / 失败怎么判>
**反证条件**:<出现什么就说明本判定不成立>
**遗留**:<未决项 + owner>
<!-- 机器可读 · 引用登记(正文已自包含,本段不参与人读) <!-- 机器可读 · 引用登记(正文已自包含,本段不参与人读)
ref-owner: <外部资料拥有者> ref-owner: <外部资料拥有者>
refs: refs:
- <外部编号> - <外部编号>
--> -->