@rowansenne/workflow-run 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -1
- package/README.md +60 -38
- package/bin/lib/artifact-context.mjs +46 -0
- package/bin/lib/core.mjs +5 -1
- package/bin/lib/docnexus.mjs +6 -6
- package/bin/lib/doctor.mjs +1 -2
- package/bin/lib/install.mjs +1 -1
- package/bin/lib/uninstall.mjs +3 -0
- package/bin/workflow-run.mjs +20 -29
- package/code-graph/SKILL.md.tpl +2 -7
- package/create-release-md-doc/SKILL.md.tpl +9 -8
- package/create-tech-md-doc/SKILL.md.tpl +9 -8
- package/database-query/SKILL.md.tpl +2 -0
- package/database-query/skill.json +2 -2
- package/dev/SKILL.md.tpl +42 -116
- package/dev/references/bug.md +30 -0
- package/dev/references/delivery-template.md +6 -6
- package/dev/references/delivery.md +48 -0
- package/dev/references/implementation.md +19 -0
- package/dev/references/plan-template.md +5 -3
- package/dev/references/planning.md +45 -0
- package/dev/references/preflight.md +35 -0
- package/dev/skill.json +2 -2
- package/md-to-wiki/SKILL.md.tpl +5 -1
- package/md-to-wiki/skill.json +2 -2
- package/package.json +2 -2
- package/project-review/SKILL.md.tpl +46 -72
- package/project-review/references/report.md.tpl +4 -4
- package/project-review/references/reporting.md +48 -0
- package/project-review/references/review-method.md +56 -0
- package/project-review/references/verification.md +30 -0
- package/shared/prd-fetch.md +1 -1
- package/workflow/SKILL.md.tpl +15 -16
- package/workflow/lib/artifact-model.mjs +83 -0
- package/workflow/lib/capabilities.mjs +80 -0
- package/workflow/lib/document-model.mjs +86 -0
- package/workflow/lib/meta-store.mjs +112 -0
- package/workflow/lib/project.mjs +218 -0
- package/workflow/lib/requirement-read.mjs +68 -0
- package/workflow/lib/requirements.mjs +219 -0
- package/workflow/lib/runs.mjs +144 -0
- package/workflow/lib/workspace-read.mjs +120 -0
- package/workflow/references/architecture.md +43 -0
- package/workflow/references/capabilities.md +39 -0
- package/workflow/references/codegraph.md +3 -3
- package/workflow/references/docnexus.md +1 -1
- package/workflow/references/requirements.md +25 -13
- package/workflow/references/runs.md +98 -0
- package/workflow/references/status.md +6 -3
- package/workflow/references/templates.md +11 -14
- package/workflow/scripts/artifacts.mjs +21 -80
- package/workflow/scripts/doc-sections.mjs +2 -55
- package/workflow/scripts/docnexus-layout.mjs +2 -24
- package/workflow/scripts/knowledge.mjs +4 -2
- package/workflow/scripts/lint-doc.mjs +2 -35
- package/workflow/scripts/meta-store.mjs +2 -0
- package/workflow/scripts/pr-desc.mjs +8 -6
- package/workflow/scripts/report.mjs +20 -68
- package/workflow/scripts/requirement.mjs +9 -402
- package/workflow/scripts/resolve.mjs +22 -190
- package/workflow/scripts/review-scope.mjs +2 -1
- package/workflow/scripts/run.mjs +37 -0
- package/workflow/scripts/tables.mjs +1 -1
- package/workflow/scripts/template.mjs +2 -6
- package/workflow/scripts/view.mjs +41 -21
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 发现复核与严重级别
|
|
2
|
+
|
|
3
|
+
进入第 6 步前读取;所有审查模式共用,不因仅在对话回复而省略严重问题复核。
|
|
4
|
+
|
|
5
|
+
## 第 6 步 — 复核
|
|
6
|
+
|
|
7
|
+
交付前逐条复核 CRITICAL / HIGH(深审时 MEDIUM 也复核;其余 MEDIUM 至少做下面第 1 条)。审查结论会被用来决定能不能合并,一条误报会让人白改代码,也会让后面的真问题不被当回事。找出问题的人自己复核容易顺着原来的思路认同自己,所以**能交给独立的一方就交出去**:
|
|
8
|
+
- **独立复核(首选)**:当前环境能派子 agent / 子任务时(例如 Claude Code 的 Agent 工具、Codex / Qwen Code 的子 agent),每条发现派一个,互不相关的可以同时派。只给它:一句话结论、位置(文件:行号)、你写的触发条件、对比分支(或「未提交改动」)。**不给你的推理过程**,也不告诉它你认为成立。要它自己读代码,按下面 1~4 条检查,回答「成立 / 可能成立 / 不成立」并引用代码作为依据。
|
|
9
|
+
- **自行复核**:环境派不了子 agent 时自己做,先找它**不成立**的理由,找不到再确认成立。
|
|
10
|
+
检查项(独立复核和自行复核都按这 4 条):
|
|
11
|
+
1. **定位**:把所有发现(包括 MEDIUM / LOW)写成 `workflow-run/tmp/review/findings.json`(数组,每条 `{ "file", "code"(原代码片段), "line"(你估的行号,可选), "title" }`),运行 `node <skills>/workflow/scripts/locate.mjs workflow-run/tmp/review/findings.json`(带上和第 1 步一样的 `--diff` / `--base`)。报告里的行号一律用它输出的 `start_line`~`end_line`,不用自己数的。`anchored: false` 的回去重读代码、照原样改片段再跑一次,还找不到就降为 MEDIUM 并标「⚠ 待确认」;`ambiguous` 时看 `candidates` 确认是哪一处;`in_change: false` 说明问题不在本次改动的行上,按第 4 条判断是不是本次引入的。
|
|
12
|
+
2. 写出**触发条件**:什么输入 / 状态下会出错、错成什么样。写不出来 → 降为 MEDIUM 并标「⚠ 待确认」。
|
|
13
|
+
3. 找有没有别处已经兜住:调用方校验、`@Validated` / 全局异常处理、数据库约束、框架默认行为、配置。涉及索引、字段、唯一约束、表大小的发现,以第 2 步拉到的实际表结构为准(复核列写「已与 <连接名> 核对」)。已兜住 → 删掉或降级,并在说明里写依据。
|
|
14
|
+
4. 确认是本次改动引入的:改动前就存在的问题移到「未覆盖与待确认」,不计入合并结论。
|
|
15
|
+
结果:「成立」在报告「复核」列标「✅」;「可能成立」标「⚠」;「不成立」从问题表里删掉(独立复核否掉的,在「未覆盖与待确认」里记一句「复核排除 N 条」)。复核方式(独立 / 自行)写进报告「审查方式」元信息。
|
|
16
|
+
|
|
17
|
+
## Severity Levels
|
|
18
|
+
|
|
19
|
+
- **CRITICAL**:崩溃 / 数据错误 / 资金影响 / 安全漏洞,必须修复后才能建议合并。dev 用户明确接受遗留风险时可以记录带问题交付,报告仍保留问题与不建议合并的结论;交付文档不代表允许合并或上线
|
|
20
|
+
- **HIGH**:明显 bug,特定场景可触发
|
|
21
|
+
- **MEDIUM**:潜在问题,边界未处理或语义存疑
|
|
22
|
+
- **LOW**:有实际收益的优化建议(性能、可维护性);风格类只在深审时报
|
|
23
|
+
|
|
24
|
+
## Verification
|
|
25
|
+
|
|
26
|
+
- 每个结论都要能定位到具体文件+行号,并引用真实代码片段;行号来自 `locate.mjs`。
|
|
27
|
+
- 读不到的内容如实说明,不要臆测。
|
|
28
|
+
- 涉及跨分支对比时,确保 diff 基于正确的 base 分支(默认 `main`)。
|
|
29
|
+
- CRITICAL / HIGH 都经过第 6 步复核(能派子 agent 时独立复核),写得出触发条件;复核不了的标「⚠ 待确认」,不计入「不建议合并」的结论。
|
|
30
|
+
- `review-scope.mjs` 的每个文件都有交代:`review` 里的都审过,没精读的逐个列在「未覆盖与待确认」;`skipped` 按原因汇总写进同一节(例如「跳过 12 个:生成文件 8、文档 3、敏感文件 1」,敏感文件单独点名提醒)。
|
package/shared/prd-fetch.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
任一步失败都**只回退**(不用 PRD 继续),在产出中写明「PRD 增强已跳过」,绝不因此中止主流程。
|
|
6
6
|
|
|
7
|
-
1. **wiki
|
|
7
|
+
1. **wiki 通道**:复用开工能力快照的 `wiki.channel`(见 `<skills>/workflow/references/capabilities.md`),不重复探测。无通道时回退;不要读取或输出凭据。
|
|
8
8
|
2. **解析 pageId**:纯数字直接用;URL 用 `[?&]pageId=(\d+)` 或 `/pages/(\d+)` 提取;提取不出 → 请用户提供 pageId,或回退。
|
|
9
9
|
3. **拉取页面**(按可用性):
|
|
10
10
|
- MCP:`get_page({pageId, expand: "body.storage,version"})`;
|
package/workflow/SKILL.md.tpl
CHANGED
|
@@ -5,13 +5,16 @@ description: "Entry point for this project's dev workflow skills: routes a reque
|
|
|
5
5
|
|
|
6
6
|
# Workflow
|
|
7
7
|
|
|
8
|
+
**开工能力**:先运行 `node <skills>/workflow/scripts/resolve.mjs status`,按 `<skills>/workflow/references/capabilities.md` 选择本轮能力与模板;同一项目链路复用已有快照,失败统一按该规则处理。
|
|
9
|
+
|
|
8
10
|
本项目个人开发工作流的入口:决定由哪个 skill 处理请求,维护所有 skill 共用的**项目画像**,并统一**增强能力探测**。本 skill 自身不写业务代码。
|
|
9
11
|
|
|
10
12
|
## Command Format
|
|
11
13
|
|
|
12
14
|
```
|
|
13
15
|
/workflow # 根据用户请求路由;无具体请求时输出能力地图 + 状态
|
|
14
|
-
/workflow status #
|
|
16
|
+
/workflow status # 能力探测 + 当前分支运行进度(含已完成)
|
|
17
|
+
/workflow resume [<运行id>] # 读取 meta.json 中的状态,核对后续接
|
|
15
18
|
/workflow profile # 填写或更新项目画像
|
|
16
19
|
/workflow report # 本地效果报告(审查发现与修复率、需求澄清、沉淀与复用、各分支链路)
|
|
17
20
|
/workflow view # 项目文档视图:按需求浏览方案、交付、审查、技术 / 上线方案(本地静态页面)
|
|
@@ -31,9 +34,10 @@ description: "Entry point for this project's dev workflow skills: routes a reque
|
|
|
31
34
|
| 根据分支 diff 出上线方案 | `create-release-md-doc` | 可带 `--tech` 复用技术方案 |
|
|
32
35
|
| 把 md 发布到 wiki | `md-to-wiki` | 需 wiki 已配置 |
|
|
33
36
|
| 写 PR / MR 描述、提 PR | `workflow/scripts/pr-desc.mjs` | 汇总本分支交付说明、审查报告、技术 / 上线方案;创建 PR 需用户同意 |
|
|
37
|
+
| 继续上次任务、恢复中断的流程 | `/workflow resume` | 先读运行状态和上下文,等待中的决定不能自动跳过 |
|
|
34
38
|
| 这套工作流效果怎么样、用得怎么样 | `/workflow report` | 只读统计 `workflow-run/docs/requirements/` 下的产物 |
|
|
35
39
|
| 看看项目文档、某个需求留下了哪些文档、审查记录 | `/workflow view` | 生成本地静态页面,按需求浏览 |
|
|
36
|
-
| 改技术方案 / 上线方案 / 交付说明 / 审查报告的模板,按样例建模板 | `/workflow template` | 草稿 → 校验与影响 → 用户确认后写入 DocNexus
|
|
40
|
+
| 改技术方案 / 上线方案 / 交付说明 / 审查报告的模板,按样例建模板 | `/workflow template` | 草稿 → 校验与影响 → 用户确认后写入 DocNexus(知识库是模板唯一来源) |
|
|
37
41
|
| 记住 / 查找项目决策与约定 | DocNexus(入口 skill `docnexus`) | 需已接入;写入必须用户授权 |
|
|
38
42
|
|
|
39
43
|
意图明确时直接按对应 skill 执行,不必先输出这张表。目标 skill 没有安装(`resolve.mjs status` 输出的 `skills` 里没有它,安装时用了 `--skills` 子集)→ 告诉用户可以运行 `npx @rowansenne/workflow-run --skills <名称>` 补装,不要假装执行。意图横跨多个阶段时按下面的「完整链路」逐段推进,每段结束告诉用户下一段是什么,由用户决定是否继续。走到哪一步不靠记忆:运行 `node <skills>/workflow/scripts/artifacts.mjs [--branch <分支>]`,它扫描 `workflow-run/docs/requirements/` 下的产物,按元信息行里的分支归组,输出每类最新的一份、wiki 发布记录和 `next`(下一步建议)。
|
|
@@ -41,7 +45,8 @@ description: "Entry point for this project's dev workflow skills: routes a reque
|
|
|
41
45
|
### 完整链路(从需求到上线)
|
|
42
46
|
|
|
43
47
|
```
|
|
44
|
-
dev
|
|
48
|
+
dev(确定工作分支 → 定需求名 → 理解需求 → 方案 → 实现 → 测试 → 优先独立子 agent 审查并落盘报告,无则在当前会话自查 → 用户选择修复或交付)
|
|
49
|
+
↳ 修复:实现 → 验证 → 复审(无子 agent 时当前会话)→ 再次选择;交付:完整流程生成 delivery.md,S 快速通道只在对话交付、不落盘
|
|
45
50
|
→ create-tech-md-doc(M / L 规模)
|
|
46
51
|
→ md-to-wiki(发布技术方案,可选)
|
|
47
52
|
→ create-release-md-doc(要上线时)
|
|
@@ -49,7 +54,7 @@ dev(需求 → 方案 → 实现 → 测试 → 自审)
|
|
|
49
54
|
→ DocNexus 沉淀(可选,需授权)
|
|
50
55
|
```
|
|
51
56
|
|
|
52
|
-
|
|
57
|
+
开始完整链路时,先进入 dev 的 Step 0 确定本次工作分支,再运行 `node <skills>/workflow/scripts/requirement.mjs which` 确认需求名(见 `references/requirements.md`)。入口只转交用户明确给出或确认的名字,不把切换前从旧分支自动读到的名字当成用户已确认的需求名。**这里只问、不 bind**,等第一次真的要落盘产物时再由对应 skill `bind`。这样从入口进来也可以走 S 快速通道,不会提前创建需求目录和 `meta.json`。
|
|
53
58
|
|
|
54
59
|
提交、推送、发布 wiki、写入 DocNexus 都是对外动作,每一步都要用户明确同意。
|
|
55
60
|
|
|
@@ -62,32 +67,26 @@ dev(需求 → 方案 → 实现 → 测试 → 自审)
|
|
|
62
67
|
| `references/profile.md` | 项目画像 `profile.json` 各字段含义(含开发环境配置 `toolchain` / `env_vars`)、填写与更新步骤(`resolve.mjs infer-profile`、`env --check`) |
|
|
63
68
|
| `references/templates.md` | 模板来源判定(`resolve.mjs template`)、创建与修改模板(`/workflow template`)、模板随知识库更新 |
|
|
64
69
|
| `references/docnexus.md` | 写入 DocNexus 的分类(`resolve.mjs category`)、按分类参考知识库(`knowledge.mjs`) |
|
|
70
|
+
| `references/capabilities.md` | 开工快照、必装组件门槛、可选增强通道与统一失败处理 |
|
|
65
71
|
| `references/status.md` | `/workflow status` 的输出格式 |
|
|
72
|
+
| `references/runs.md` | 完整流程在 meta.json 中的运行状态、步骤完成标记、保存与恢复、运行完成时间 |
|
|
66
73
|
| `references/requirements.md` | 产物按需求归档(`workflow-run/docs/requirements/<需求名>/<项目名>/`):什么时候问需求名、`requirement.mjs` 绑定分支与给出落盘路径、`meta.json` |
|
|
67
74
|
| `references/tables.md` | 确定改动涉及哪些表(`tables.mjs`)、只读拉取实际表结构 / 索引 / 行数核对(`query.mjs --schema --indexes`),没有库连接时怎么退回 |
|
|
68
75
|
| `references/codegraph.md` | 用 CodeGraph 代码图谱读代码、查调用链与影响面(MCP 工具与 CLI 对照、图谱看不到的部分) |
|
|
69
76
|
|
|
70
|
-
`/workflow profile` → 按 `references/profile.md` 执行;`/workflow status` → 按 `references/status.md` 输出;`/workflow report` → 运行 `node <skills>/workflow/scripts/report.mjs [--since YYYY-MM-DD]`,原样给出输出的 Markdown,再用两三句话指出最值得关注的数字(例如修复率低、带未验证项的交付多、从不沉淀),不要编造报告里没有的数据。`/workflow view` → 运行 `node <skills>/workflow/scripts/view.mjs`(用户要直接打开时加 `--open`),把生成的页面路径告诉用户;页面只读、不联网,数据是生成那一刻的快照,产物有更新时重新生成。`/workflow template` → 按 `references/templates.md`「修改模板」一路走完:起草稿、按用户的要求改、校验并说明影响、用户确认后保存并确认生效;没说改哪份模板时先运行 `template.mjs list` 问用户。
|
|
77
|
+
`/workflow resume` → 按 `references/runs.md`「新会话恢复」读取 `run.mjs show [--id <运行id>]`,先核对项目根、分支、代码和证据,再进入对应 skill 的保存阶段;已完成运行只展示完成标记与时间,不能自动重启。`/workflow profile` → 按 `references/profile.md` 执行;`/workflow status` → 按 `references/status.md` 输出;`/workflow report` → 运行 `node <skills>/workflow/scripts/report.mjs [--since YYYY-MM-DD]`,原样给出输出的 Markdown,再用两三句话指出最值得关注的数字(例如修复率低、带未验证项的交付多、从不沉淀),不要编造报告里没有的数据。`/workflow view` → 运行 `node <skills>/workflow/scripts/view.mjs`(用户要直接打开时加 `--open`),把生成的页面路径告诉用户;页面只读、不联网,数据是生成那一刻的快照,产物有更新时重新生成。`/workflow template` → 按 `references/templates.md`「修改模板」一路走完:起草稿、按用户的要求改、校验并说明影响、用户确认后保存并确认生效;没说改哪份模板时先运行 `template.mjs list` 问用户。
|
|
71
78
|
|
|
72
79
|
## 增强能力探测
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
| 能力 | 判定为可用 | 用途 |
|
|
77
|
-
|---|---|---|
|
|
78
|
-
| wiki | `md-to-wiki/wiki-config.json` 中 `enabled=true` 且 `wiki.base_url` 非空;或环境存在 wiki MCP 工具 | 读 PRD(各 skill 按 `references/prd-fetch.md`,REST 通道统一调 `md-to-wiki/scripts/upload.mjs get`)、发布文档 |
|
|
79
|
-
| DocNexus | `.docnexus/project.json` 为 format 7 且 `output_dir=workflow-run`,CLI 可执行:`.docnexus/node_modules/.bin/docnexus`(`resolve.mjs status` 的 `docnexus.bin`);命令用 `node <skills>/workflow/scripts/docnexus.mjs <参数>` 运行(`docnexus.run`),见 `references/docnexus.md`「DocNexus 的运行位置」 | 查约定与历史决策、沉淀知识、提供文档模板 |
|
|
80
|
-
| 数据库 | `database-query/config.json` 存在且 `connections` 非空 | 查表结构、核对数据 |
|
|
81
|
-
| 代码图谱(CodeGraph) | 项目根有 `.codegraph/` 索引,且 `codegraph` 命令可用(`resolve.mjs status` 的 `codegraph.ok`) | 读代码、查调用链与影响面、找受影响的测试;用法见 `references/codegraph.md` |
|
|
82
|
-
| 项目画像 | `workflow/profile.json` 中 `main_branch` 与 `build` / `test` 非空 | dev 构建与测试 |
|
|
83
|
-
| 开发环境配置 | `profile.json` 的 `toolchain`(可被 `profile.local.json` 覆盖)配了 JDK / Maven / Node 的路径;`resolve.mjs env --check` 逐个核对在本机是否存在 | dev 跑编译与测试(跑不起来时先看它) |
|
|
81
|
+
能力探测与选择统一由 `resolve.mjs status` 和 `references/capabilities.md` 负责。DocNexus 是必装组件;开工检查不通过时先修复。CodeGraph、wiki、数据库作为可选增强,按本轮能力快照选择通道,不在各步骤重复探测或复制降级规则。模板只从 DocNexus 读取,缺失处理见 `references/templates.md`。
|
|
84
82
|
|
|
85
83
|
## Rules
|
|
86
84
|
|
|
85
|
+
- 普通聊天生成的报告、分析、方案、草稿或导出等辅助文件默认放在项目根的 `workflow-run/other/`,不因分支已绑定需求而写入需求目录,也不调用需求归档或运行状态命令。工作流正式产物仍按 `references/requirements.md` 归档;源码、测试、配置和已有文件的维护、工作流指定的临时文件与知识库沿用各自目录,用户明确指定路径时以用户为准。
|
|
87
86
|
- 本 skill 只路由和维护 `profile.json`,不写业务代码;具体工作交给对应 skill,并遵守该 skill 的规则。
|
|
88
87
|
- `profile.json` 只记录项目事实(含 `toolchain` 开发环境配置:JDK / Maven / Node 的路径),**不写密码、token、内网凭据**;路径里的变量在 `env_vars` 里声明。
|
|
89
88
|
- 写 `toolchain` 前先用 `resolve.mjs env --check` 核对路径在本机存在;本机对不上、又只是个人差异的,写进个人的 `profile.local.json`(覆盖 `profile.json`),不要改团队那份。
|
|
90
|
-
- 用户的请求明显只属于某个 skill
|
|
89
|
+
- 用户的请求明显只属于某个 skill 时,直接进入对应 skill,并复用本轮开工能力快照。
|
|
91
90
|
|
|
92
91
|
## 终端命令
|
|
93
92
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// 正式需求产物的统一纯模型;不扫描目录、不修改状态。
|
|
2
|
+
import { sections, section, tableRows, listItems, meta, isEmpty } from './document-model.mjs';
|
|
3
|
+
|
|
4
|
+
export const FILES = { plan: 'plan', delivery: 'delivery', review: 'review', tech: 'tech-doc', release: 'release-doc' };
|
|
5
|
+
export const LABELS = { plan: '方案', delivery: '交付说明', review: '审查报告', tech: '技术方案', release: '上线方案' };
|
|
6
|
+
export const KINDS = Object.keys(FILES).map((kind) => ({ kind, label: LABELS[kind] }));
|
|
7
|
+
|
|
8
|
+
// 文件名 → { kind, version };不是需求目录里的产物返回 null
|
|
9
|
+
export function parseFile(name) {
|
|
10
|
+
for (const [kind, base] of Object.entries(FILES)) {
|
|
11
|
+
const m = new RegExp(`^${base}(?:_v(\\d+))?\\.md$`).exec(name);
|
|
12
|
+
if (m) return { kind, version: m[1] ? Number(m[1]) : 1 };
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const head = (text, n = 20) => text.split(/\r?\n/).slice(0, n).join('\n');
|
|
18
|
+
export function parseBranch(text) {
|
|
19
|
+
const m = head(text).match(/(?:对比)?分支[::]\s*`?([^`\s||←,,))]+)`?/);
|
|
20
|
+
return m && !/^[{<]/.test(m[1]) ? m[1] : null;
|
|
21
|
+
}
|
|
22
|
+
export function parseWiki(text) {
|
|
23
|
+
const m = /^<!-- md-to-wiki: ([^\n]*?) -->/.exec(text);
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
const kv = Object.fromEntries([...m[1].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
|
|
26
|
+
return kv.pageId ? { pageId: kv.pageId, version: kv.version ? Number(kv.version) : null, url: kv.url ?? null, at: kv.at ?? null } : null;
|
|
27
|
+
}
|
|
28
|
+
export function parseDocnexus(text) {
|
|
29
|
+
const v = /DocNexus[::]\s*([^|\n]*)/.exec(head(text))?.[1]?.trim();
|
|
30
|
+
return v && /docnexus\/library\//.test(v) ? v.split(/[、,,]\s*/).map((s) => s.trim()).filter(Boolean) : null;
|
|
31
|
+
}
|
|
32
|
+
// 文档头里的日期(跳过第一行的 md-to-wiki 发布记录等 HTML 注释,那里的 at= 是发布时间)
|
|
33
|
+
export const headerDate = (text) => /(\d{4}-\d{2}-\d{2})/.exec(head(text.replace(/<!--[\s\S]*?-->/g, ''), 8))?.[1] ?? null;
|
|
34
|
+
|
|
35
|
+
const LEVELS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
|
36
|
+
|
|
37
|
+
// DocNexus 元信息行 → captured / not_captured / not_asked / not_needed / not_connected / missing
|
|
38
|
+
export function captureState(text) {
|
|
39
|
+
const v = meta(text, 'DocNexus');
|
|
40
|
+
if (!v) return 'missing';
|
|
41
|
+
if (/docnexus\/library\//.test(v)) return 'captured';
|
|
42
|
+
if (/未接入/.test(v)) return 'not_connected';
|
|
43
|
+
if (/未询问/.test(v)) return 'not_asked';
|
|
44
|
+
if (/无需沉淀/.test(v)) return 'not_needed';
|
|
45
|
+
if (/未沉淀/.test(v)) return 'not_captured';
|
|
46
|
+
return 'missing';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 正文(元信息行之外)有没有引用知识库文档
|
|
50
|
+
export const citesKnowledge = (text) => text.split(/\r?\n/).some((l) => !/^>\s*DocNexus[::]/.test(l) && /docnexus\/library\//.test(l));
|
|
51
|
+
|
|
52
|
+
export function parseReview(text) {
|
|
53
|
+
const secs = sections(text);
|
|
54
|
+
const levels = Object.fromEntries(LEVELS.map((l) => [l, 0]));
|
|
55
|
+
let verified = 0; let pending = 0;
|
|
56
|
+
for (const cells of tableRows(section(secs, '严重问题'))) {
|
|
57
|
+
const lv = LEVELS.find((l) => cells[1]?.toUpperCase().includes(l));
|
|
58
|
+
if (lv) levels[lv]++;
|
|
59
|
+
const last = cells[cells.length - 1] ?? '';
|
|
60
|
+
if (last.includes('✅')) verified++; else if (last.includes('⚠')) pending++;
|
|
61
|
+
}
|
|
62
|
+
levels.MEDIUM += tableRows(section(secs, '一般问题')).length;
|
|
63
|
+
levels.LOW += tableRows(section(secs, '优化建议')).length;
|
|
64
|
+
const followUp = { fixed: 0, open: 0, obsolete: 0 };
|
|
65
|
+
for (const cells of tableRows(section(secs, '与上次审查对比'))) {
|
|
66
|
+
const s = cells[2] ?? '';
|
|
67
|
+
if (s.includes('已修复')) followUp.fixed++; else if (s.includes('仍存在')) followUp.open++; else if (s.includes('不再适用')) followUp.obsolete++;
|
|
68
|
+
}
|
|
69
|
+
return { levels, verified, pending, follow_up: followUp, is_rereview: followUp.fixed + followUp.open + followUp.obsolete > 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseDelivery(text) {
|
|
73
|
+
const secs = sections(text);
|
|
74
|
+
const type = meta(text, '类型');
|
|
75
|
+
const scale = meta(text, '规模');
|
|
76
|
+
const unverified = section(secs, '验证')?.match(/未验证项[::]\s*(.*)/)?.[1]?.trim();
|
|
77
|
+
return {
|
|
78
|
+
type: type && /缺陷/.test(type) ? 'bug' : type ? 'feature' : null,
|
|
79
|
+
scale: scale && /^[SML]$/.test(scale) ? scale : null,
|
|
80
|
+
decisions: listItems(section(secs, '已确认的决定')).length,
|
|
81
|
+
has_unverified: !!unverified && !isEmpty(unverified) && !/^\{\{/.test(unverified),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// DocNexus format 7:项目根运行,内部状态在 .docnexus/,知识输出固定到 workflow-run/docnexus/。
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const DOCNEXUS_INTERNAL = '.docnexus';
|
|
8
|
+
export const DOCNEXUS_OUTPUT = 'workflow-run';
|
|
9
|
+
export const docnexusRoot = (root) => path.resolve(root);
|
|
10
|
+
export const docnexusWorkspace = (root) => path.resolve(root, DOCNEXUS_OUTPUT, 'docnexus');
|
|
11
|
+
export const docnexusInternal = (root) => path.resolve(root, DOCNEXUS_INTERNAL);
|
|
12
|
+
|
|
13
|
+
export function docnexusConfigured(root) {
|
|
14
|
+
try {
|
|
15
|
+
const marker = JSON.parse(fs.readFileSync(path.join(docnexusInternal(root), 'project.json'), 'utf8'));
|
|
16
|
+
return marker.format_version === 7 && marker.output_dir === DOCNEXUS_OUTPUT;
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function docnexusBinPath(root) {
|
|
21
|
+
const name = process.platform === 'win32' ? 'docnexus.cmd' : 'docnexus';
|
|
22
|
+
const bin = path.join(docnexusInternal(root), 'node_modules', '.bin', name);
|
|
23
|
+
return fs.existsSync(bin) ? bin : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const docnexusConnected = (root) => docnexusConfigured(root) && docnexusBinPath(root) !== null;
|
|
27
|
+
|
|
28
|
+
// 集中探测接入状态;不连接远端服务、不创建索引、不输出凭据。
|
|
29
|
+
|
|
30
|
+
const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
31
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
32
|
+
export const DOCNEXUS_HINT = 'DocNexus 是必装组件,运行 npx @rowansenne/workflow-run 修复接入后继续;不使用默认模板或跳过知识库。';
|
|
33
|
+
export const docnexusBin = (root) => { const bin = docnexusBinPath(root); return bin ? path.relative(path.resolve(root), bin) : null; };
|
|
34
|
+
|
|
35
|
+
export function codegraphStatus(root) {
|
|
36
|
+
const indexed = fs.existsSync(path.join(root, '.codegraph'));
|
|
37
|
+
const cli = spawnSync('codegraph', ['--version'], { cwd: root, encoding: 'utf8', shell: process.platform === 'win32', env: { ...process.env, DO_NOT_TRACK: '1' }, timeout: 30000 }).status === 0;
|
|
38
|
+
const script = path.join(SKILLS_DIR, 'code-graph', 'scripts', 'cg.mjs');
|
|
39
|
+
const scan = fs.existsSync(script);
|
|
40
|
+
let library = { ok: false, error: indexed ? 'skill_missing' : 'no_index' };
|
|
41
|
+
if (indexed && scan) {
|
|
42
|
+
const r = spawnSync(process.execPath, [script, 'status', '--root', root], { cwd: root, encoding: 'utf8', timeout: 30000 });
|
|
43
|
+
try {
|
|
44
|
+
const result = JSON.parse(r.stdout);
|
|
45
|
+
if (r.status === 0 && result.library) library = { ...result.library, ok: !result.library.error };
|
|
46
|
+
else library = { ok: false, error: 'probe_failed' };
|
|
47
|
+
} catch { library = { ok: false, error: 'probe_failed' }; }
|
|
48
|
+
}
|
|
49
|
+
const mode = indexed && library.ok ? 'script' : indexed && cli ? 'cli' : 'source';
|
|
50
|
+
return {
|
|
51
|
+
ok: mode !== 'source', cli, indexed, library, scan, mode,
|
|
52
|
+
how: mode !== 'source' ? null : !indexed ? '是否运行 codegraph init 建索引由用户决定;本次直接读源码' : library.hint ?? '重跑 npx @rowansenne/workflow-run --with-codegraph 修复接入',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function capabilities(root) {
|
|
57
|
+
const wiki = readJson(path.join(SKILLS_DIR, 'md-to-wiki', 'wiki-config.json'));
|
|
58
|
+
const db = readJson(path.join(SKILLS_DIR, 'database-query', 'config.json'));
|
|
59
|
+
const dn = docnexusConnected(root);
|
|
60
|
+
const rest = wiki?.enabled === true && !!wiki?.wiki?.base_url;
|
|
61
|
+
return {
|
|
62
|
+
ready: dn,
|
|
63
|
+
blockers: dn ? [] : [{ capability: 'docnexus', hint: DOCNEXUS_HINT }],
|
|
64
|
+
docnexus: {
|
|
65
|
+
ok: dn, required: true,
|
|
66
|
+
bin: dn ? `./${docnexusBin(root).split(path.sep).join('/')}` : null,
|
|
67
|
+
root: '.', internal: DOCNEXUS_INTERNAL, workspace: `${DOCNEXUS_OUTPUT}/docnexus`, output_dir: DOCNEXUS_OUTPUT,
|
|
68
|
+
run: `node ${path.relative(root, path.join(SKILLS_DIR, 'workflow', 'scripts', 'docnexus.mjs')).split(path.sep).join('/')}`,
|
|
69
|
+
how: dn ? null : DOCNEXUS_HINT,
|
|
70
|
+
},
|
|
71
|
+
wiki: { ok: rest, channel: rest ? 'rest' : null, mcp: 'unknown', how: '配置 md-to-wiki/wiki-config.json,或由 agent 在开工时补充当前会话的 wiki MCP 通道;配置存在不代表鉴权和网络已验证' },
|
|
72
|
+
codegraph: codegraphStatus(root),
|
|
73
|
+
database: {
|
|
74
|
+
ok: !!db?.connections && Object.keys(db.connections).length > 0,
|
|
75
|
+
default: db?.default_connection ?? (db?.connections?.default ? 'default' : null),
|
|
76
|
+
connections: Object.fromEntries(Object.entries(db?.connections ?? {}).map(([k, c]) => [k, { driver: c.driver ?? 'mysql', readonly: c.readonly !== false }])),
|
|
77
|
+
how: '重跑 npx @rowansenne/workflow-run 补配数据库',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Markdown 纯文本模型:标题、章节、表格和元信息;不依赖文件、模板或命令入口。
|
|
2
|
+
|
|
3
|
+
// 去掉代码块后返回 [{ line, text }],行号从 1 开始
|
|
4
|
+
export function proseLines(md) {
|
|
5
|
+
const out = [];
|
|
6
|
+
let fence = null;
|
|
7
|
+
md.replace(/\r/g, '').split('\n').forEach((text, i) => {
|
|
8
|
+
const m = text.match(/^\s*(```+|~~~+)/);
|
|
9
|
+
if (m) { if (!fence) fence = m[1][0]; else if (m[1][0] === fence) fence = null; return; }
|
|
10
|
+
if (!fence) out.push({ line: i + 1, text });
|
|
11
|
+
});
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 章节标题归一化:去掉编号(1. / 4.1 / 一、/ 第一部分)、必填标记 *、可选标记(如有),用于模板与文档比对
|
|
16
|
+
export function headingKey(text) {
|
|
17
|
+
return text
|
|
18
|
+
.replace(/\*+\s*$/, '')
|
|
19
|
+
.replace(/[((]\s*如有[^))]*[))]/g, '')
|
|
20
|
+
.replace(/^\s*(?:第?[一二三四五六七八九十]+[、..]|[0-9]+(?:\.(?:[0-9]+|x))*[.、.]?)\s*/, '')
|
|
21
|
+
.replace(/\s+/g, '')
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function headings(md) {
|
|
26
|
+
return proseLines(md)
|
|
27
|
+
.map(({ line, text }) => ({ line, m: text.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/) }))
|
|
28
|
+
.filter((h) => h.m)
|
|
29
|
+
.map(({ line, m }) => ({
|
|
30
|
+
line, level: m[1].length, text: m[2],
|
|
31
|
+
key: headingKey(m[2]),
|
|
32
|
+
required: /\*\s*$/.test(m[2]),
|
|
33
|
+
optional: /[((]\s*如有/.test(m[2]),
|
|
34
|
+
placeholder: /\{\{|<[^>]*填写[^>]*>/.test(m[2]),
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const stripComments = (s) => s.replace(/<!--[\s\S]*?-->/g, '');
|
|
39
|
+
|
|
40
|
+
// 二级标题(归一化后的 key)→ 正文(去掉 HTML 注释与首尾空行)
|
|
41
|
+
export function sections(text) {
|
|
42
|
+
const lines = text.split(/\r?\n/);
|
|
43
|
+
const hs = headings(text);
|
|
44
|
+
const out = new Map();
|
|
45
|
+
hs.forEach((h, i) => {
|
|
46
|
+
if (h.level !== 2) return;
|
|
47
|
+
const next = hs.slice(i + 1).find((x) => x.level <= 2);
|
|
48
|
+
const body = lines.slice(h.line, next ? next.line - 1 : lines.length).join('\n');
|
|
49
|
+
if (!out.has(h.key)) out.set(h.key, stripComments(body).trim());
|
|
50
|
+
});
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 按标题前缀取一节(「严重问题」匹配「严重问题(CRITICAL / HIGH)」),前缀同样归一化
|
|
55
|
+
export function section(secs, prefix) {
|
|
56
|
+
const key = headingKey(prefix);
|
|
57
|
+
for (const [k, v] of secs) if (k.startsWith(key)) return v;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 「无」「无。」或只剩占位符的节当作空
|
|
62
|
+
export const isEmpty = (body) => !body || /^(无|无。|none|n\/a)$/i.test(body.trim()) || /^\{\{[^}]*\}\}$/.test(body.trim());
|
|
63
|
+
|
|
64
|
+
// 表格数据行(跳过表头、分隔行、全空行和带 {{占位符}} 的模板行),返回单元格数组
|
|
65
|
+
export function tableRows(body) {
|
|
66
|
+
if (!body) return [];
|
|
67
|
+
const lines = body.split('\n').filter((l) => /^\s*\|/.test(l));
|
|
68
|
+
return lines.slice(1)
|
|
69
|
+
.filter((l) => !/^\s*\|[\s:|-]+\|\s*$/.test(l))
|
|
70
|
+
.map((l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()))
|
|
71
|
+
.filter((cells) => cells.some((c) => c) && !cells.some((c) => /\{\{/.test(c)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 列表项(- / * / 1.),跳过「无」与占位符
|
|
75
|
+
export function listItems(body) {
|
|
76
|
+
if (!body) return [];
|
|
77
|
+
return body.split('\n').map((l) => /^\s*(?:[-*]|\d+[.)])\s+(.*)$/.exec(l)?.[1]?.trim()).filter((x) => x && !isEmpty(x) && !/\{\{/.test(x));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 开头引用块里的元信息:「> DocNexus:xxx」取 xxx(遇到「|」截断)
|
|
81
|
+
export function meta(text, key) {
|
|
82
|
+
const head = text.split(/\r?\n/).slice(0, 20).join('\n');
|
|
83
|
+
const m = new RegExp(`${key}[::]\\s*([^|\\n]*)`).exec(head);
|
|
84
|
+
const v = m?.[1]?.trim();
|
|
85
|
+
return v && !/^\{\{/.test(v) ? v : null;
|
|
86
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 需求索引和运行状态共用同一把短时写锁;原子替换,避免读到半份 JSON。
|
|
2
|
+
// 锁和临时文件都放在 workflow-run/tmp/ 下(不放需求目录),崩溃残留不会混进 git;
|
|
3
|
+
// 锁目录里的唯一进程标记记录 pid 和时间;只回收已退出进程的旧标记,非空目录不能删除。
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
const STALE_MS = 30_000;
|
|
9
|
+
const LOCK_DIR = 'workflow-run/tmp/locks'; // 与 requirement.mjs 的 HOME / TMP_DIR 一致;不 import 以免循环依赖
|
|
10
|
+
|
|
11
|
+
export function readMeta(file) {
|
|
12
|
+
if (!fs.existsSync(file)) return {};
|
|
13
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
14
|
+
let value;
|
|
15
|
+
try { value = JSON.parse(text); }
|
|
16
|
+
catch { throw new Error(`需求元信息不是合法 JSON,已停止写入以免覆盖:${file}(修复或删除该文件后重试)`); }
|
|
17
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`无效的需求元信息:${file}`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 固定长度,避免中文需求名与项目名拼接后超过文件名限制,也避免分隔符替换碰撞。
|
|
22
|
+
function lockPath(root, file) {
|
|
23
|
+
const target = path.join(fs.realpathSync.native(path.dirname(file)), path.basename(file));
|
|
24
|
+
const rel = path.relative(fs.realpathSync.native(root), target).split(path.sep).join('/');
|
|
25
|
+
const key = createHash('sha256').update(rel).digest('hex');
|
|
26
|
+
return path.join(root, LOCK_DIR, `${key}.lock`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const alive = (pid) => {
|
|
30
|
+
if (!pid) return false;
|
|
31
|
+
try { process.kill(pid, 0); return true; }
|
|
32
|
+
catch (e) { return e.code === 'EPERM'; } // 存在但不属于当前用户
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const busy = (lock) => new Error(`需求元信息正在写入,请稍后重试:${lock}`);
|
|
36
|
+
const expired = (file) => Date.now() - fs.statSync(file).mtimeMs > STALE_MS;
|
|
37
|
+
|
|
38
|
+
function removeEmptyLock(lock) {
|
|
39
|
+
try { fs.rmdirSync(lock); return true; }
|
|
40
|
+
catch (e) {
|
|
41
|
+
if (e.code === 'ENOENT') return true;
|
|
42
|
+
if (e.code === 'ENOTEMPTY' || e.code === 'EEXIST') return false;
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reclaim(lock) {
|
|
48
|
+
try {
|
|
49
|
+
const owners = fs.readdirSync(lock);
|
|
50
|
+
if (!owners.length && !expired(lock)) return false;
|
|
51
|
+
for (const name of owners) {
|
|
52
|
+
const pid = /^(\d+)-[\da-f-]+\.json$/.exec(name)?.[1];
|
|
53
|
+
const owner = path.join(lock, name);
|
|
54
|
+
if (!pid || alive(Number(pid)) || !expired(owner)) return false;
|
|
55
|
+
// UUID 标记不复用。即使另一个进程已重建目录,也不会删到它的标记。
|
|
56
|
+
try { fs.unlinkSync(owner); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
57
|
+
}
|
|
58
|
+
return removeEmptyLock(lock);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (e.code === 'ENOENT') return !fs.existsSync(lock);
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function acquire(lock) {
|
|
66
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
67
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
68
|
+
try { fs.mkdirSync(lock); }
|
|
69
|
+
catch (e) {
|
|
70
|
+
if (e.code !== 'EEXIST') throw e;
|
|
71
|
+
if (reclaim(lock)) continue;
|
|
72
|
+
throw busy(lock);
|
|
73
|
+
}
|
|
74
|
+
const name = `${process.pid}-${randomUUID()}.json`;
|
|
75
|
+
const owner = path.join(lock, name);
|
|
76
|
+
const release = () => {
|
|
77
|
+
try { fs.unlinkSync(owner); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
78
|
+
removeEmptyLock(lock);
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
fs.writeFileSync(owner, `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`, { flag: 'wx' });
|
|
82
|
+
// mkdir 后进程若停顿到空目录过期,其他进程可能已经重建目录。
|
|
83
|
+
const owners = fs.readdirSync(lock);
|
|
84
|
+
if (owners.length !== 1 || owners[0] !== name) throw busy(lock);
|
|
85
|
+
return release;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
release();
|
|
88
|
+
if (e.code === 'ENOENT') throw busy(lock);
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw busy(lock);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function updateMeta(file, update, { root = process.cwd() } = {}) {
|
|
96
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
97
|
+
const lock = lockPath(root, file);
|
|
98
|
+
const release = acquire(lock);
|
|
99
|
+
const tmp = path.join(path.dirname(lock), `${path.basename(file)}.${randomUUID()}.tmp`);
|
|
100
|
+
try {
|
|
101
|
+
const next = update(readMeta(file));
|
|
102
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
103
|
+
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== text) {
|
|
104
|
+
fs.writeFileSync(tmp, text);
|
|
105
|
+
fs.renameSync(tmp, file);
|
|
106
|
+
}
|
|
107
|
+
return next;
|
|
108
|
+
} finally {
|
|
109
|
+
fs.rmSync(tmp, { force: true });
|
|
110
|
+
release();
|
|
111
|
+
}
|
|
112
|
+
}
|