merge: 结构评估先行(本席 47f82df)+ 参数两写法 / selftest / DSH 接法(bae7302)

两条并行改动落在同一批文件上 ⇒ 逐个文件按「谁更对」合入,未整边取用:

【取 bae7302】
- bin/ticket.mjs parseArgs:取值型选项 `--x=v` 与 `--x v` 两种写法都收(本席旧版只认等号);
  本席新增的 `--dump` 一并纳入 VALUE_OPTS。
- bin/ticket.mjs `selftest`(不联网、不需要令牌)—— 本席在其上补 4 条 §8 结构判定用例(共 13 条)。
- README「接到 DSH」整节:技能发现只有一层、逐技能 junction、rank 表、用户级接法
  (本席原稿「整仓当作技能根」是错的,已弃)。
- SKILL §6「参数写法的坑(已修 · 留档)」;templates 的「域内层级用 - 而非 /」提示行。

【取本席】
- SKILL:闸门 A 先评结构(§8.0)+ 闸门 B 只记要点(§8.2)+ 四段任务全貌(§8.1)
  + 结构有效性与整洁性自检(§8.3);修 §1 规则 2 与 §2 的双括号/单括号自相矛盾。
- bin/ticket.mjs:assess / update / comments(含 --dump 删前备份)/ comment-add / comment-del。
- templates:正文改四段骨架。README:技能清单行。

【同步废止的旧口径】
- SKILL §9「`--repo` 必须用等号形式」⇒ 改为「两种写法都可 · 旧口径已废止」,并指向 §6 成因留档。

【验证 · E1 · 本机】
- `node --check` 通过;`selftest` 13/13;`verify 157 158 159` = 21/21;
- 两种参数写法各跑一次 `verify` 均 7/7;`assess` 对三单正文的段位 / 顺序 / 空壳判定与实况相符。
This commit is contained in:
2026-09-24 10:26:30 +08:00
4 changed files with 85 additions and 14 deletions
+26 -7
View File
@@ -1,12 +1,12 @@
# agents-skills
> 可复用的 **agent 技能库**(执行方法,不含项目结论)。每个技能一个**顶层目录** + `SKILL.md`,因此本仓**可以直接作为技能根**(如 junction 到 `<项目>/.dsh/skills` 后由 DSH 发现)。
> 可复用的 **agent 技能库**(执行方法,不含项目结论)。每个技能一个**顶层目录** + `SKILL.md`,按 DSH 的发现规则逐个接到技能根下即可(见「使用」)。
## 技能清单
| 技能 | 覆盖 | 主要文件 |
|---|---|---|
| `ticket-management` | Gitea 工单的「人读 + 机读」双面管理,且**始终维护结构有效性与整洁性**:**动工单前先评结构**(四段任务全貌 = 问题背景 / 处置提案 / 处置结果 / 效果对比 → 出「落点表」)· **只记要点**(改不了全貌的不进工单)· 标题【问题域】(闸门并入同一括号)· 正文首行机读 · 正文自包含 · 来源只进末尾机读引用块 · `verify`(有效性)+ `assess`(结构体检) | `SKILL.md` · `bin/ticket.mjs` · `templates/issue.md` |
| `ticket-management` | Gitea 工单的「人读 + 机读」双面管理,并**始终维护结构有效性与整洁性**:**动工单前先评结构**(四段任务全貌 = 问题背景 / 处置提案 / 处置结果 / 效果对比 → 出「落点表」)· **只记要点**(改不了全貌的不进工单)· 标题问题域前缀 · 正文首行机读 · 正文自包含 · 来源只进末尾机读引用块 · `verify`(有效性)+ `assess`(结构体检)+ 离线 `selftest` | `SKILL.md` · `bin/ticket.mjs` · `templates/issue.md` |
## 约定
@@ -16,11 +16,30 @@
4. **工具要能自检**:脚本必须能在自己的仓里跑通一条最小用例。
5. **令牌不落盘**:脚本从 `GITEA_TOKEN` 或 `--token-file` 读,**绝不把令牌写进仓库、URL 或提交**。
## 使用(把本仓接到项目)
## 使用(把本仓接到 DSH)
DSH 的技能发现**只有一层**:技能根下的 `<name>/SKILL.md` 或平铺 `<name>.md`,**不认**嵌套的 `**/SKILL.md`。所以要把**每个技能目录**接到技能根下,不能把本仓整体 junction 成 `<技能根>/agents-skills`(那层里没有 `SKILL.md`,会发现不到)。
技能根与优先级(同名时 rank 小者胜):
| rank | 来源 | 路径 |
|---|---|---|
| 100 | 项目 | `<项目根>/.dsh/skills` |
| 200 | 项目 | `<项目根>/.agents/skills` |
| 400 | 用户 | `~/.dsh/skills`(即 `$DSH_HOME/skills`) |
```powershell
# 方式一:作为技能根之一(Windows 目录 junction)
New-Item -ItemType Junction -Path '<项目>/.dsh/skills/agents-skills' -Target '<本仓路径>'
# 方式二:只取一个技能
Copy-Item -Recurse '<本仓>/ticket-management' '<项目>/.dsh/skills/'
# 方式一:用户级 —— 所有项目都可见(推荐;本机已按此接好 ticket-management)
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.dsh\skills"
New-Item -ItemType Junction -Path "$env:USERPROFILE\.dsh\skills\ticket-management" -Target '<本仓>\ticket-management'
# 方式二:项目级 —— 只在该项目可见
New-Item -ItemType Junction -Path '<项目>/.dsh/skills/ticket-management' -Target '<本仓>\ticket-management'
# 方式三:拷一份 —— 与本仓脱钩,之后改动不再同步
Copy-Item -Recurse '<本仓>\ticket-management' '<技能根>\'
```
junction 指向本仓 ⇒ 在这里改 `SKILL.md`,DSH 的下一次目录扫描即生效:**正文编辑不用重启,也不用重新链接**。技能名取自 frontmatter 的 `name`;技能根下的新增 / 改名 / 删除由 DSH 的目录监视自动发现。
> 进阶:也可让本仓**整体**充当一个技能根(`@deepseek-ai/dsh-skill-filesystem` 的 `customSkillDirs`,rank 300),但要在部署侧给该插件配目录;默认预设不带这行配置。
+8 -2
View File
@@ -1,6 +1,6 @@
---
name: ticket-management
description: 用 Gitea 工单库做「人读 + 机读」双面管理,并**始终维护工单的结构有效性与整洁性**。**动工单(新建或更新)前先做结构评估**:把本轮信息按「问题背景 / 处置提案 / 处置结果 / 效果对比」四段**任务全貌**出「落点表」,再过「只记要点」四问闸门(改不了全貌的不进工单);正文 = 现行唯一权威叙事,评论只留必须可执行物,外部来源只进末尾机读引用块。含 bin/ticket.mjs 的 labels/milestones/list/verify/assess/create/update/comments/comment-add/comment-del/rename。适用于:建单、更新工单正文或评论、整理精简既有工单、校验工单是否合规、用 API 读写 Gitea issue。
description: 用 Gitea 工单库做「人读 + 机读」双面管理,并**始终维护工单的结构有效性与整洁性**。**动工单(新建或更新)前先做结构评估**:把本轮信息按「问题背景 / 处置提案 / 处置结果 / 效果对比」四段**任务全貌**出「落点表」,再过「只记要点」四问闸门(改不了全貌的不进工单);正文 = 现行唯一权威叙事,评论只留必须可执行物,外部来源只进末尾机读引用块。含 bin/ticket.mjs 的 labels/milestones/list/verify/assess/create/update/comments/comment-add/comment-del/rename,以及不联网、不需要令牌的 selftest 自检。适用于:建单、更新工单正文或评论、整理精简既有工单、校验工单是否合规、用 API 读写 Gitea issue。
---
# ticket-management(Gitea 工单管理)
@@ -84,7 +84,11 @@ refs:
## 5 工具 `bin/ticket.mjs`
```bash
# 自检:不联网、不需要令牌 —— 改完脚本先跑这条
node bin/ticket.mjs selftest
# 令牌:GITEA_TOKEN 环境变量,或 --token-file=<含 key=value 的文件>(不落盘、不回显)
# 取值型选项两种写法等价:--repo=xbc/xbc 与 --repo xbc/xbc
node bin/ticket.mjs labels --repo=xbc/xbc
node bin/ticket.mjs milestones --repo=xbc/xbc
node bin/ticket.mjs list --repo=xbc/xbc --state=open
@@ -103,6 +107,7 @@ node bin/ticket.mjs rename 142 --repo=xbc/xbc --domain=平台 [--gate=等口径]
|---|---|---|
| `labels` / `milestones` / `list` | 读(标签 id 是创建时唯一可传的形式) | `read:issue` |
| `verify <n>` | **校验**(只读):见 §7 七项断言(含标签非空) | `read:issue` |
| `selftest` | **自检**(不联网、不需要令牌):解析器两种写法 + §7 七项断言正反 + §8 结构判定 | 无 |
| `assess <n>` / `assess --body-file=f` | **结构评估**(只读):四段现状 / 缺段 / 空壳段 + 评论清单 ⇒ §8.0 落点表底稿 | `read:issue` |
| `create` | 建单(**先按标题查重**;**`--label` 必传**,缺失 / id 不存在 ⇒ 拒建) | `write:issue` |
| `update <n>` | 改正文(**标题与正文同一次 PATCH**,§8.1 第 4 条);过自检,不过 ⇒ 拒改 | `write:issue` |
@@ -116,6 +121,7 @@ node bin/ticket.mjs rename 142 --repo=xbc/xbc --domain=平台 [--gate=等口径]
- **HTTP 分块解码**:`let d = ''; res.on('data', c => d += c)` 会把**跨块的多字节字符**打成 `U+FFFD`(中文标签名就被打坏过)⇒ 必须 `res.setEncoding('utf8')`,或用 `fetch` 的 `text()`。
- **建单前必须查重**:按标题查,否则会重复建单。
- **标签传 id**:`POST /issues` 的 `labels` 是**标签 id 数组**,不是名字。
- **参数写法的坑(已修 · 留档)**:早先 `parseArgs` 只认 `--repo=o/r`,而文档示例写的是 `--repo o/r` ⇒ `xbc/xbc` 被当成位置参数、`repo` 变成布尔 `true`,请求打到 `/repos/true/…`,报出来的是 **403「缺 read 权限」/ 405「方法不允许」—— 全是假故障**,白排查三轮。现在**取值型选项两种写法都收**;改完脚本一律先跑 `selftest` 兜底。
- **批量改首行时必须保持 ` | ` 形态**(空格 + 竖线 + 空格):把 `域=…` 换成新值时若顺手吃掉它后面的空格,`闸门=` 会与 `域` 黏成一段 ⇒ **断言 4 全线 FAIL**(实测 14/14 中招,工具拦住了)。
- **改标题与正文**:都是 `PATCH /repos/{owner}/{repo}/issues/{n}`;**要同一次提交**(用 `update`,见 §8.1 第 4 条)。
- **令牌 scope**:只读 `read:issue,read:repository` 建不了单(403,报错会写 required scope);建 / 改需 `write:issue`;`GET /user` 另需 `read:user`。
@@ -216,7 +222,7 @@ node bin/ticket.mjs rename 142 --repo=xbc/xbc --domain=平台 [--gate=等口径]
## 9 本工具实测补充(2026-09-24)
- **`--repo` 必须用等号形式**:`--repo=xbc/xbc`。写成空格形式(`--repo xbc/xbc`)时,`xbc/xbc` 会被当成**单号**去请求 ⇒ 报 `读取失败 status=404` 且后面紧跟一行 `#xbc/xbc 读取失败` —— 这是**参数错位**,不是权限问题。
- **`--repo` 两种写法都可以**:`--repo=xbc/xbc` 与 `--repo xbc/xbc`。❗**旧口径已废止**(早期只认等号形式,示例写成空格 ⇒ 参数错位,症状是 403 / 405 / 404 的**假故障**);**改完脚本先跑 `selftest`**。
- **令牌**:`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`。
+50 -5
View File
@@ -21,11 +21,18 @@ const SECTIONS = [
];
const OPTIONAL_SECTIONS = ["验收判据", "反证条件", "遗留"];
// 取值型选项:`--repo=o/r` 与 `--repo o/r` 两种写法都收(等价;布尔开关不吃后一个参数)
const VALUE_OPTS = ["repo", "title", "body-file", "label", "milestone", "token-file", "host", "state", "limit", "domain", "gate", "dump"];
function parseArgs(argv) {
const pos = [], opt = {};
for (const a of argv) {
if (a.startsWith("--")) { const i = a.indexOf("="); if (i < 0) opt[a.slice(2)] = true; else opt[a.slice(2, i)] = a.slice(i + 1); }
else pos.push(a);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith("--")) { pos.push(a); continue; }
const j = a.indexOf("=");
if (j >= 0) { opt[a.slice(2, j)] = a.slice(j + 1); continue; }
const k = a.slice(2), next = argv[i + 1];
if (VALUE_OPTS.indexOf(k) >= 0 && next !== undefined && !next.startsWith("--")) { opt[k] = next; i++; }
else opt[k] = true;
}
return { pos: pos, opt: opt };
}
@@ -138,6 +145,42 @@ function printStructure(body) {
console.log(" 空壳段:" + (st.empty.length ? "✗ " + st.empty.join(" · ") + "(删掉或补齐)" : "无"));
console.log(" 可选段:" + (st.optional.length ? st.optional.join(" · ") : "无"));
}
// 自检(不联网、不需要令牌):node bin/ticket.mjs selftest
function cmdSelftest() {
let pass = 0, fail = 0;
const t = function (name, ok) { if (ok) { pass++; console.log(" PASS " + name); } else { fail++; console.log(" FAIL " + name); } };
// —— 解析器:两种写法等价 · 布尔开关不吃后一个参数 ——
const p1 = parseArgs(["list", "--repo", "xbc/xbc", "--state=open"]);
t("空格写法 --repo xbc/xbc", p1.opt.repo === "xbc/xbc" && p1.pos[0] === "list" && p1.opt.state === "open");
const p2 = parseArgs(["verify", "142", "--repo=xbc/xbc"]);
t("等号写法 --repo=xbc/xbc", p2.opt.repo === "xbc/xbc" && p2.pos[1] === "142");
const p3 = parseArgs(["create", "--repo=xbc/xbc", "--dry-run"]);
t("布尔开关不吃后面的参数", p3.opt["dry-run"] === true && p3.opt.repo === "xbc/xbc");
const p4 = parseArgs(["comments", "158", "--dump", "_bak.md"]);
t("新增取值型选项同规则(--dump)", p4.opt.dump === "_bak.md" && p4.pos[1] === "158");
// —— §7 七项断言:合规样例 + 反例 ——
const ok = checksOf("【报表-等口径】报表未按经营模式分列", "域=报表-统计-模式分列 | 闸门=等口径 | 优先级=本迭代 | 类型=数据 | 模式=改造 | 端=后端 | 环境=测服 | 证据=E1" + NL + NL + "正文");
t("合规样例 6/6", ok.every(function (c) { return c[1]; }));
const b1 = checksOf("【报表】报表未按经营模式分列", "域=报表-统计-模式分列 | 闸门=可开工 | 优先级=本迭代" + NL + NL + "见 codes/foo.go");
t("标题无闸门 + 首行 闸门=可开工 ⇒ 断言 4 通过", b1[3][1] === true);
t("人读部分含禁用前缀 ⇒ 断言 5 失败", b1[4][1] === false);
const b2 = checksOf("【报表-等口径】x", "域=报表-统计-模式分列 | 闸门=可开工");
t("标题带闸门但首行 闸门=可开工 ⇒ 断言 4 失败", b2[3][1] === false);
const b3 = checksOf("【报表】x", "域=报表-统计-模式分列" + NL + NL + "<!-- refs:");
t("有引用块无 ref-owner ⇒ 断言 6 失败", b3[5][1] === false);
// —— §8 结构评估:四段齐 / 缺必备段 / 空壳 / 乱序 ——
const sOk = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "坏。" + NL + NL + "## 方案" + NL + "修。");
t("背景 + 提案齐 ⇒ 顺序 OK", sOk.hits.length === 2 && sOk.ordered === true && sOk.empty.length === 0);
const sMiss = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "坏。");
t("缺必备段(提案)可被识别", sMiss.hits.some(function (x) { return x.idx === 1; }) === false);
const sEmpty = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "坏。" + NL + NL + "## 处置结果" + NL + NL + "## 遗留" + NL + "x");
t("空壳段可被识别", sEmpty.empty.length === 1);
const sOrder = structureOf("域=平台-性能-索引" + NL + NL + "## 效果对比" + NL + "a" + NL + NL + "## 现象" + NL + "b");
t("乱序可被识别", sOrder.ordered === false);
console.log("自检 " + pass + "/" + (pass + fail));
return fail === 0 ? 0 : 1;
}
async function findTitle(api, repo, title) {
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; });
@@ -209,7 +252,7 @@ async function cmdAssess(api, opt, n) {
}
async function cmdCreate(api, opt) {
const title = opt.title;
if (!title) { console.log("用法:create --repo=o/r --title=... --body-file=... --label=<id>[,<id>] [--dry-run] [--force]"); return 1; }
if (!title) { console.log("用法:create --repo=o/r --title=... --body-file=... --label=<id>[,<id>] [--dry-run] [--force](--repo o/r 亦可)"); return 1; }
const sp = splitTitle(title);
if (!sp.parts.length || DOMAINS.indexOf(sp.parts[0]) < 0) { console.log("拒绝:标题主前缀不合规 —— " + title); return 1; }
const dup = await findTitle(api, opt.repo, title);
@@ -331,7 +374,8 @@ async function cmdRename(api, opt, n) {
}
const USAGE = [
"用法:ticket.mjs <子命令> --repo=owner/name [...]",
"用法:ticket.mjs <子命令> --repo=owner/name [...](取值型选项 --x=v 与 --x v 两种写法等价)",
" selftest 离线自检(不联网、不需要令牌)",
" labels | milestones 读标签 / 里程碑",
" list [--state=open|closed|all] 列工单",
" verify <n...> 七项断言(有效性:标题/首行/闸门/自包含/引用块/标签)",
@@ -347,6 +391,7 @@ const USAGE = [
const A = parseArgs(process.argv.slice(2));
const cmd = A.pos[0];
if (cmd === "selftest") process.exit(cmdSelftest());
if (!cmd || cmd === "-h" || cmd === "--help") { console.log(USAGE); process.exit(0); }
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);
+1
View File
@@ -1,4 +1,5 @@
域=<L1>-<L2>-<L3> | 闸门=<可开工|等口径|等取证|等部署> | 优先级=<立即|本迭代|排队> | 类型=<安全|数据|性能|可用|债务> | 模式=<…|全模式> | 端=<后端|前端|小程序|运维> | 环境=<正式服|测服> | 证据=<E1|E2|需生产取证>
(域内层级用**半角连字符** `-`:断言 3 取 `域` 的 `-` 前第一段与标题主域比对,写成斜杠必挂)
## 问题背景
**现象**:<谁在什么场景下会看到什么坏结果>