@rowansenne/workflow-run 0.26.1 → 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 +21 -0
- package/README.md +59 -37
- package/bin/lib/artifact-context.mjs +46 -0
- package/bin/lib/docnexus.mjs +4 -4
- 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
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
|
+
}
|