sdd-flow-kit 1.0.18 → 1.0.25
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/README.md +10 -6
- package/dist/adapters/index.js +2 -2
- package/dist/core/inferProject.js +152 -0
- package/dist/postinstall.js +42 -39
- package/dist/steps/startFlow.js +20 -7
- package/package.json +1 -1
- package/src/templates/artifacts/01-adi-doc-skill-/346/214/207/345/274/225.template.md +24 -10
- package/src/templates/artifacts/05-openspec-/346/217/220/346/241/210/350/215/211/347/250/277.template.md +2 -1
- package/src/templates/artifacts/06-agent-skill.template.md +29 -22
- package/src/templates/artifacts/06-opsx-auto-chain.template.md +38 -0
- package/src/templates/prompts/02-sdd-loop-prompt.md +4 -2
- package/src/templates/rules/sdd-no-implement-until-apply.mdc.template +9 -4
- package/src/templates/skills/confluence-doc.py.template +248 -66
- package/src/templates/skills/doc-skill.SKILL.md.template +13 -4
package/README.md
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
1. 进入你要开发的项目目录
|
|
17
17
|
2. 安装:`pnpm add -D sdd-flow-kit `
|
|
18
|
-
|
|
19
|
-
- `ls .
|
|
20
|
-
- `ls docs
|
|
18
|
+
检查是否安装(Cursor 用户看 `.cursor`,不是 `.claude`):
|
|
19
|
+
- `ls .cursor/skills/sdd-flow-kit/SKILL.md`
|
|
20
|
+
- `ls docs/adi-doc-skill/SKILL.md`(adInsight 应为 **adi**,不是 oms)
|
|
21
|
+
- Claude Code 用户:`ls .claude/skills/sdd-flow-kit/SKILL.md`
|
|
22
|
+
|
|
23
|
+
> 若在 `adInsight-web/frontend` 等子目录安装,工具会**向上识别**仓库名;仍不对时:`SDD_FLOW_KIT_PROJECT=ADI pnpm add -D sdd-flow-kit` 或 `npx sdd-flow-kit install --project ADI -y`
|
|
21
24
|
3. 若 skill 未生成,执行手动兜底:
|
|
22
25
|
- `node ./node_modules/sdd-flow-kit/dist/postinstall.js`(轻量,秒级)
|
|
23
26
|
- 或 `npx sdd-flow-kit install -y`(完整环境)
|
|
@@ -56,10 +59,11 @@
|
|
|
56
59
|
##### 需求澄清:用户无需背提示词
|
|
57
60
|
|
|
58
61
|
`install` 会写入 `.cursor/rules/sdd-flow-kit-gates.mdc` 与触发 Skill。
|
|
59
|
-
澄清阶段你**直接粘贴确认点**(编号列表即可),AI
|
|
60
|
-
|
|
62
|
+
澄清阶段你**直接粘贴确认点**(编号列表即可),AI 先进入文档模式更新 `openspec/PRD/**`。
|
|
63
|
+
**默认**:03 闭环并生成 01/02/04 后,**同一回合自动** `/opsx-propose`(01+02+UI)→ `/opsx-apply`,无需再说「继续」。
|
|
64
|
+
说「只提案」或「先别写代码」可停在提案阶段;设 `autoChainOpsx: false` 可恢复人工分步。
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
阶段口语(可选,用于断点续跑):`继续` / `做提案` / `开始 apply`。
|
|
63
67
|
|
|
64
68
|
##### `/opsx-apply` 与 TDD + Playwright 检查机制
|
|
65
69
|
|
package/dist/adapters/index.js
CHANGED
|
@@ -42,8 +42,8 @@ async function runClaudeFlow(args) {
|
|
|
42
42
|
"1) 01-获取需求文档指引:回填 source/PRD.md(及接口文档,如有)。",
|
|
43
43
|
"2) 02-需求分析:若矛盾点未闭合,仅更新 03-待确认问题清单.md 并停止;用户确认后再生成 01/02/闭环03。",
|
|
44
44
|
"3) 用户确认后补全 04-技术文档草稿.md。",
|
|
45
|
-
"4)
|
|
46
|
-
"5)
|
|
45
|
+
"4) 03 闭环后默认同一回合:/opsx-propose(01+02+UI)→ /opsx-apply → delivery-pipeline exit 0(见 06-opsx-自动串联指引.md)。",
|
|
46
|
+
"5) 仅当用户说「只提案」或 autoChainOpsx=false 时,在 propose 后暂停等待 apply。",
|
|
47
47
|
"完成后输出一句:SDD flow completed",
|
|
48
48
|
"",
|
|
49
49
|
"以下是第2步提示词:",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.PRODUCT_PREFIX_BY_KIND = exports.DOC_SKILL_BY_KIND = exports.ALLOWED_PROJECTS = void 0;
|
|
7
|
+
exports.productToKind = productToKind;
|
|
8
|
+
exports.resolveDocSkillDir = resolveDocSkillDir;
|
|
9
|
+
exports.inferProjectKind = inferProjectKind;
|
|
10
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
exports.ALLOWED_PROJECTS = ["OMS", "ADI", "欢盟", "AD Tools"];
|
|
13
|
+
exports.DOC_SKILL_BY_KIND = {
|
|
14
|
+
ADI: "adi-doc-skill",
|
|
15
|
+
OMS: "oms-doc-skill",
|
|
16
|
+
欢盟: "huan-doc-skill",
|
|
17
|
+
"AD Tools": "ad-tools-doc-skill",
|
|
18
|
+
};
|
|
19
|
+
exports.PRODUCT_PREFIX_BY_KIND = {
|
|
20
|
+
ADI: "ADI",
|
|
21
|
+
OMS: "OMS",
|
|
22
|
+
欢盟: "HUAN",
|
|
23
|
+
"AD Tools": "ADTOOLS",
|
|
24
|
+
};
|
|
25
|
+
function productToKind(product) {
|
|
26
|
+
const p = product.trim().toUpperCase();
|
|
27
|
+
if (p === "ADI" || p.includes("ADINSIGHT"))
|
|
28
|
+
return "ADI";
|
|
29
|
+
if (p === "OMS" || p.includes("OPERATION"))
|
|
30
|
+
return "OMS";
|
|
31
|
+
if (p === "欢盟" || p === "HUAN")
|
|
32
|
+
return "欢盟";
|
|
33
|
+
if (p.includes("AD TOOLS") || p === "ADTOOLS")
|
|
34
|
+
return "AD Tools";
|
|
35
|
+
return "OMS";
|
|
36
|
+
}
|
|
37
|
+
/** 从 projectRoot 向上查找 docs/*-doc-skill(适配 frontend 子目录 + 根目录 docs) */
|
|
38
|
+
async function resolveDocSkillDir(projectRoot, kind) {
|
|
39
|
+
const dirName = exports.DOC_SKILL_BY_KIND[kind];
|
|
40
|
+
let cur = node_path_1.default.resolve(projectRoot);
|
|
41
|
+
for (let i = 0; i < 8; i += 1) {
|
|
42
|
+
const candidate = node_path_1.default.join(cur, "docs", dirName);
|
|
43
|
+
if (await pathExists(node_path_1.default.join(candidate, "confluence-doc.py"))) {
|
|
44
|
+
const rel = node_path_1.default.relative(projectRoot, candidate);
|
|
45
|
+
return {
|
|
46
|
+
absPath: candidate,
|
|
47
|
+
relFromRoot: rel.startsWith("..") ? rel : node_path_1.default.join("docs", dirName),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const parent = node_path_1.default.dirname(cur);
|
|
51
|
+
if (parent === cur)
|
|
52
|
+
break;
|
|
53
|
+
cur = parent;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
absPath: node_path_1.default.join(projectRoot, "docs", dirName),
|
|
57
|
+
relFromRoot: node_path_1.default.join("docs", dirName),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function pathExists(filePath) {
|
|
61
|
+
try {
|
|
62
|
+
await promises_1.default.access(filePath);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function matchKindInText(text) {
|
|
70
|
+
const t = text.toLowerCase().replace(/_/g, "-");
|
|
71
|
+
if (t.includes("adinsight") || t.includes("adi-web") || /(^|\/|-)adi(-|\/|$)/.test(t)) {
|
|
72
|
+
return "ADI";
|
|
73
|
+
}
|
|
74
|
+
if (t.includes("operation-web") || t.includes("operation") || /(^|\/|-)oms(-|\/|$)/.test(t)) {
|
|
75
|
+
return "OMS";
|
|
76
|
+
}
|
|
77
|
+
if (t.includes("huan-union") || t.includes("huanunion") || /\bhm\b/.test(t)) {
|
|
78
|
+
return "欢盟";
|
|
79
|
+
}
|
|
80
|
+
if (t.includes("ad-tools") || t.includes("adtools")) {
|
|
81
|
+
return "AD Tools";
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/** 从 projectRoot 向上查找已存在的 docs/*-doc-skill */
|
|
86
|
+
async function inferFromExistingDocSkill(projectRoot) {
|
|
87
|
+
let dir = projectRoot;
|
|
88
|
+
for (let i = 0; i < 8; i += 1) {
|
|
89
|
+
for (const kind of exports.ALLOWED_PROJECTS) {
|
|
90
|
+
const rel = node_path_1.default.join(dir, "docs", exports.DOC_SKILL_BY_KIND[kind]);
|
|
91
|
+
if (await pathExists(node_path_1.default.join(rel, "SKILL.md"))) {
|
|
92
|
+
return kind;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const parent = node_path_1.default.dirname(dir);
|
|
96
|
+
if (parent === dir)
|
|
97
|
+
break;
|
|
98
|
+
dir = parent;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
/** 从目录路径(含父级)推断产品类型 */
|
|
103
|
+
function inferFromPathSegments(projectRoot) {
|
|
104
|
+
const resolved = node_path_1.default.resolve(projectRoot);
|
|
105
|
+
const parts = resolved.split(node_path_1.default.sep);
|
|
106
|
+
for (let i = parts.length; i > 0; i -= 1) {
|
|
107
|
+
const segment = parts.slice(0, i).join(node_path_1.default.sep);
|
|
108
|
+
const hit = matchKindInText(segment);
|
|
109
|
+
if (hit)
|
|
110
|
+
return hit;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
async function inferFromPackageJson(dir) {
|
|
115
|
+
try {
|
|
116
|
+
const pkgText = await promises_1.default.readFile(node_path_1.default.join(dir, "package.json"), "utf8");
|
|
117
|
+
const pkg = JSON.parse(pkgText);
|
|
118
|
+
const blob = `${pkg.name ?? ""} ${pkg.description ?? ""}`;
|
|
119
|
+
return matchKindInText(blob);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 推断 OMS / ADI / 欢盟 / AD Tools。
|
|
127
|
+
* monorepo 子目录(如 adInsight-web/frontend)会向上扫描路径,避免误判为 OMS。
|
|
128
|
+
*/
|
|
129
|
+
async function inferProjectKind(projectRoot) {
|
|
130
|
+
const fromEnv = (process.env.SDD_FLOW_KIT_PROJECT ?? "").trim();
|
|
131
|
+
if (exports.ALLOWED_PROJECTS.includes(fromEnv)) {
|
|
132
|
+
return fromEnv;
|
|
133
|
+
}
|
|
134
|
+
// 路径优先于已存在的 docs skill(避免 frontend 子目录曾误装 oms-doc-skill 后一直错下去)
|
|
135
|
+
const fromPath = inferFromPathSegments(projectRoot);
|
|
136
|
+
if (fromPath)
|
|
137
|
+
return fromPath;
|
|
138
|
+
let dir = projectRoot;
|
|
139
|
+
for (let i = 0; i < 6; i += 1) {
|
|
140
|
+
const fromPkg = await inferFromPackageJson(dir);
|
|
141
|
+
if (fromPkg)
|
|
142
|
+
return fromPkg;
|
|
143
|
+
const parent = node_path_1.default.dirname(dir);
|
|
144
|
+
if (parent === dir)
|
|
145
|
+
break;
|
|
146
|
+
dir = parent;
|
|
147
|
+
}
|
|
148
|
+
const fromExisting = await inferFromExistingDocSkill(projectRoot);
|
|
149
|
+
if (fromExisting)
|
|
150
|
+
return fromExisting;
|
|
151
|
+
return "OMS";
|
|
152
|
+
}
|
package/dist/postinstall.js
CHANGED
|
@@ -1,56 +1,55 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
5
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
39
|
const node_path_1 = __importDefault(require("node:path"));
|
|
7
|
-
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
8
40
|
const node_process_1 = __importDefault(require("node:process"));
|
|
41
|
+
const inferProject_1 = require("./core/inferProject");
|
|
9
42
|
const opsxDelivery_1 = require("./core/opsxDelivery");
|
|
10
43
|
const setupProject_1 = require("./steps/setupProject");
|
|
11
|
-
const ALLOWED_PROJECTS = ["OMS", "ADI", "欢盟", "AD Tools"];
|
|
12
|
-
async function inferProjectKind(projectRoot) {
|
|
13
|
-
const fromEnv = (node_process_1.default.env.SDD_FLOW_KIT_PROJECT ?? "").trim();
|
|
14
|
-
if (ALLOWED_PROJECTS.includes(fromEnv)) {
|
|
15
|
-
return fromEnv;
|
|
16
|
-
}
|
|
17
|
-
const base = node_path_1.default.basename(projectRoot).toLowerCase();
|
|
18
|
-
const hit = (s) => base.includes(s);
|
|
19
|
-
if (hit("adi") || hit("adinsight"))
|
|
20
|
-
return "ADI";
|
|
21
|
-
if (hit("oms") || hit("operation"))
|
|
22
|
-
return "OMS";
|
|
23
|
-
if (hit("huan-union") || hit("hm"))
|
|
24
|
-
return "欢盟";
|
|
25
|
-
if (hit("ad-tools") || hit("adtools") || hit("tools"))
|
|
26
|
-
return "AD Tools";
|
|
27
|
-
try {
|
|
28
|
-
const pkgText = await promises_1.default.readFile(node_path_1.default.join(projectRoot, "package.json"), "utf8");
|
|
29
|
-
const pkg = JSON.parse(pkgText);
|
|
30
|
-
const name = (pkg.name ?? "").toLowerCase();
|
|
31
|
-
const has = (s) => name.includes(s);
|
|
32
|
-
if (has("adi") || has("adinsight"))
|
|
33
|
-
return "ADI";
|
|
34
|
-
if (has("oms") || has("operation"))
|
|
35
|
-
return "OMS";
|
|
36
|
-
if (has("huan-union") || has("hm"))
|
|
37
|
-
return "欢盟";
|
|
38
|
-
if (has("ad-tools") || has("adtools") || has("tools"))
|
|
39
|
-
return "AD Tools";
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
// ignore and fallback
|
|
43
|
-
}
|
|
44
|
-
return "OMS";
|
|
45
|
-
}
|
|
46
44
|
async function inferAgent(projectRoot) {
|
|
47
45
|
const fromEnv = (node_process_1.default.env.SDD_FLOW_KIT_AGENT ?? "").trim();
|
|
48
46
|
const allowed = ["cursor", "claude-code", "codex", "openclaw"];
|
|
49
47
|
if (allowed.includes(fromEnv))
|
|
50
48
|
return fromEnv;
|
|
49
|
+
const fs = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
|
|
51
50
|
const exists = async (rel) => {
|
|
52
51
|
try {
|
|
53
|
-
await
|
|
52
|
+
await fs.access(node_path_1.default.join(projectRoot, rel));
|
|
54
53
|
return true;
|
|
55
54
|
}
|
|
56
55
|
catch {
|
|
@@ -76,7 +75,7 @@ async function main() {
|
|
|
76
75
|
const projectRoot = initCwd ?? node_process_1.default.cwd();
|
|
77
76
|
if (projectRoot === pkgDir)
|
|
78
77
|
return;
|
|
79
|
-
const projectKind = await inferProjectKind(projectRoot);
|
|
78
|
+
const projectKind = await (0, inferProject_1.inferProjectKind)(projectRoot);
|
|
80
79
|
const agent = await inferAgent(projectRoot);
|
|
81
80
|
// eslint-disable-next-line no-console
|
|
82
81
|
console.log(`[sdd-flow-kit] postinstall (light) at: ${projectRoot}`);
|
|
@@ -105,7 +104,11 @@ async function main() {
|
|
|
105
104
|
console.log("[sdd-flow-kit] opsx apply stack ready");
|
|
106
105
|
}
|
|
107
106
|
// eslint-disable-next-line no-console
|
|
108
|
-
console.log("[sdd-flow-kit] light setup done (skills/rules). Full install: npx sdd-flow-kit install -y");
|
|
107
|
+
console.log("[sdd-flow-kit] light setup done (skills/rules). Full install: npx sdd-flow-kit install --project ADI -y");
|
|
108
|
+
if (agent === "cursor") {
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.log("[sdd-flow-kit] Cursor skill: .cursor/skills/sdd-flow-kit/SKILL.md");
|
|
111
|
+
}
|
|
109
112
|
}
|
|
110
113
|
main().catch((err) => {
|
|
111
114
|
// eslint-disable-next-line no-console
|
package/dist/steps/startFlow.js
CHANGED
|
@@ -9,6 +9,7 @@ const promises_1 = __importDefault(require("node:fs/promises"));
|
|
|
9
9
|
const utils_1 = require("../core/utils");
|
|
10
10
|
const fs_1 = require("../core/fs");
|
|
11
11
|
const templates_1 = require("../core/templates");
|
|
12
|
+
const inferProject_1 = require("../core/inferProject");
|
|
12
13
|
function renderTemplate(template, vars) {
|
|
13
14
|
let out = template;
|
|
14
15
|
for (const [k, v] of Object.entries(vars)) {
|
|
@@ -22,8 +23,16 @@ async function startFlowScaffold(options) {
|
|
|
22
23
|
const outputRoot = node_path_1.default.join(options.projectRoot, "openspec", "PRD", runId);
|
|
23
24
|
const sourceDir = node_path_1.default.join(outputRoot, "source");
|
|
24
25
|
await promises_1.default.mkdir(sourceDir, { recursive: true });
|
|
25
|
-
// 1) Step1:
|
|
26
|
-
const
|
|
26
|
+
// 1) Step1: doc-skill 指引(按产品解析脚本路径,支持 monorepo frontend)
|
|
27
|
+
const kind = (0, inferProject_1.productToKind)(options.product);
|
|
28
|
+
const docSkill = await (0, inferProject_1.resolveDocSkillDir)(options.projectRoot, kind);
|
|
29
|
+
const step1Template = await (0, templates_1.loadTemplateText)("artifacts/01-adi-doc-skill-指引.template.md");
|
|
30
|
+
const step1 = renderTemplate(step1Template, {
|
|
31
|
+
PRODUCT: options.product,
|
|
32
|
+
PRODUCT_PREFIX: inferProject_1.PRODUCT_PREFIX_BY_KIND[kind],
|
|
33
|
+
DOC_SKILL_REL: docSkill.relFromRoot.replace(/\\/g, "/"),
|
|
34
|
+
VERSION_EXAMPLE: options.version,
|
|
35
|
+
});
|
|
27
36
|
await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(outputRoot, "01-获取需求文档指引.md"), step1);
|
|
28
37
|
// source 占位:让第 2 步提示词在任何工具里都能直接读
|
|
29
38
|
await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(sourceDir, "PRD.md"), [
|
|
@@ -65,6 +74,8 @@ async function startFlowScaffold(options) {
|
|
|
65
74
|
// 5) Step5 openspec 提案入口(草稿)
|
|
66
75
|
const openspecEntryTemplate = await (0, templates_1.loadTemplateText)("artifacts/05-openspec-提案草稿.template.md");
|
|
67
76
|
await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(outputRoot, "05-openspec-提案草稿.md"), openspecEntryTemplate);
|
|
77
|
+
const opsxAutoChainTemplate = await (0, templates_1.loadTemplateText)("artifacts/06-opsx-auto-chain.template.md");
|
|
78
|
+
await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(outputRoot, "06-opsx-自动串联指引.md"), opsxAutoChainTemplate);
|
|
68
79
|
await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(outputRoot, ".session-state.json"), JSON.stringify({
|
|
69
80
|
product: options.product,
|
|
70
81
|
version: options.version,
|
|
@@ -72,6 +83,7 @@ async function startFlowScaffold(options) {
|
|
|
72
83
|
outputRoot,
|
|
73
84
|
status: "awaiting_user_confirmation",
|
|
74
85
|
docModeOnly: true,
|
|
86
|
+
autoChainOpsx: true,
|
|
75
87
|
phase: "A_or_B",
|
|
76
88
|
createdAtISO: new Date().toISOString(),
|
|
77
89
|
}, null, 2));
|
|
@@ -101,12 +113,13 @@ async function startFlowScaffold(options) {
|
|
|
101
113
|
"- `02-改动点清单.md`",
|
|
102
114
|
"- `04-技术文档草稿.md`",
|
|
103
115
|
"",
|
|
104
|
-
"## Step 5
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
116
|
+
"## Step 5–6:自动串联 OPSX(默认)",
|
|
117
|
+
"03 闭环且 01/02/04 完成后,**同一 AI 回合**读 `06-opsx-自动串联指引.md`:",
|
|
118
|
+
"1. `/opsx-propose`(输入 01、02、UI;UI 默认读 `source/PRD.md` 原型目录)",
|
|
119
|
+
"2. 提案 apply-ready 后立即 `/opsx-apply` + `pnpm run opsx:delivery-pipeline`(exit 0)",
|
|
120
|
+
"勿停下来问「是否继续」。仅当用户说「只提案」或 `.session-state.json` 中 `autoChainOpsx: false` 时暂停。",
|
|
108
121
|
"",
|
|
109
|
-
"## Step 6
|
|
122
|
+
"## Step 6 明细:Apply + TDD + Playwright(阶段 D)",
|
|
110
123
|
"依次执行 skill:`openspec-apply-change` → `tdd-script` → `openspec-superpowers-pipeline` → `opsx-dev-delivery-pipeline`。",
|
|
111
124
|
"仓库根执行:`pnpm run opsx:delivery-pipeline -- <change-name>`(须 exit 0)。",
|
|
112
125
|
"禁止跳过 TDD / Playwright 裸写代码收尾。详见 `.cursor/skills/sdd-flow-kit/SKILL.md` 阶段 D。",
|
package/package.json
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
|
-
# 01-获取需求文档(
|
|
1
|
+
# 01-获取需求文档({{PRODUCT}} / {{DOC_SKILL_REL}})
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## 必须执行(不要只用 AI 猜,要跑脚本)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
1. 使用你当前 AI 工具(Cursor / Claude Code / Codex / OpenClaw)调用现有的 `adi-doc-skill`。
|
|
7
|
-
2. 命令/动作建议:让工具“获取 ADI 2.3.2 需求文档”(或等价的触发词),并把下载结果写入本目录:
|
|
8
|
-
- `source/PRD.md`(PRD 主内容)
|
|
9
|
-
- `source/接口文档.md`(接口文档,如有)
|
|
5
|
+
1. 在**仓库根**(当前工作目录,可能是 `frontend/` 或 monorepo 根)执行:
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
```bash
|
|
8
|
+
cd {{DOC_SKILL_REL}}
|
|
9
|
+
export DOC_PRODUCT_PREFIX="{{PRODUCT_PREFIX}}"
|
|
10
|
+
python3 confluence-doc.py {{VERSION_EXAMPLE}}
|
|
11
|
+
```
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
版本号可传:`2.3.2`、`V2.3.2`、`ADI_V2.3.2`、`ADI-V2.3.2`(脚本会归一化)。
|
|
15
14
|
|
|
15
|
+
2. 脚本会依次搜索:`{{PRODUCT_PREFIX}}-V*`、`{{PRODUCT_PREFIX}}_V*`、`{{PRODUCT_PREFIX}} V*`、`V*` 等。
|
|
16
|
+
|
|
17
|
+
3. 成功后把生成的 `docs/*.md` **主文档**复制/回填到本 run 目录:
|
|
18
|
+
- `source/PRD.md`
|
|
19
|
+
|
|
20
|
+
## 路径说明
|
|
21
|
+
|
|
22
|
+
- 推荐脚本目录:`{{DOC_SKILL_REL}}`(已向上解析,可能是 `docs/adi-doc-skill` 或 `../docs/adi-doc-skill`)
|
|
23
|
+
- **禁止**在 ADI 需求中使用 `docs/oms-doc-skill`(会搜 OMS 文档导致「未找到」)
|
|
24
|
+
|
|
25
|
+
## 失败排查
|
|
26
|
+
|
|
27
|
+
- 输出 `❌ 未找到`:到 Confluence 确认页面标题是否含 `{{PRODUCT_PREFIX}}-V2.3.2` 或 `{{PRODUCT_PREFIX}}_V2.3.2`
|
|
28
|
+
- `playwright` 未安装:`pip install playwright && playwright install chromium`
|
|
29
|
+
- 仍失败:在仓库根执行 `npx sdd-flow-kit install --project {{PRODUCT}} -y` 后重试
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
## 前置条件(必须全部满足)
|
|
7
7
|
- [ ] `03-待确认问题清单.md` 已标注 **矛盾点池状态:已闭合**
|
|
8
8
|
- [ ] `01-需求分析报告.md`、`02-改动点清单.md`、`04-技术文档草稿.md` 已生成
|
|
9
|
-
- [ ]
|
|
9
|
+
- [ ] 用户已确认可进入提案阶段(阶段 B 闭环)
|
|
10
|
+
- [ ] `autoChainOpsx` 为 true(默认)→ 与用户确认后**同一回合**自动 propose,无需再等「继续」
|
|
10
11
|
|
|
11
12
|
## /opsx-propose 输入清单
|
|
12
13
|
- 需求分析:`01-需求分析报告.md`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sdd-flow-kit-trigger
|
|
3
|
-
description: 开发某版本需求、SDD
|
|
3
|
+
description: 开发某版本需求、SDD 流程、待确认问题澄清、用户回复确认点/矛盾点结论时触发。阶段 A/B 仅文档;阶段 B 闭环后**自动** /opsx-propose → /opsx-apply(除非用户说只提案或 autoChainOpsx=false)。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# SDD Flow Trigger Skill
|
|
@@ -20,16 +20,18 @@ description: 开发某版本需求、SDD 流程、待确认问题澄清、用户
|
|
|
20
20
|
| 模式 | 何时 | 允许改动的路径 | 禁止 |
|
|
21
21
|
|------|------|----------------|------|
|
|
22
22
|
| **文档模式** | 阶段 A/B/C,含「用户发来确认点」 | 仅 `openspec/PRD/**`(及阶段 C 的 `openspec/changes/**`) | 任何 `src/`、`pages/`、`components/` 等业务代码 |
|
|
23
|
-
| **实现模式** |
|
|
23
|
+
| **实现模式** | 阶段 D(提案 apply-ready 后**自动进入**,无需用户再说「开始 apply」) | 按 openspec change 与 pipeline 要求 | 跳过 propose / 跳过 pipeline |
|
|
24
24
|
|
|
25
25
|
**常见错误**:用户粘贴确认点后,你一边更新 `03` 一边改 `.tsx` —— **绝对禁止**。确认回复 = 只做文档,同一回合不得 touch 业务代码。
|
|
26
26
|
|
|
27
27
|
### 用户侧:零口令(不要要求加前缀)
|
|
28
28
|
- 用户**直接粘贴**确认点、编号结论、「按 1/2/3 继续」即可;**禁止**要求用户输入【仅文档模式】等固定话术。
|
|
29
29
|
- 你须在收到后**自动**判定为阶段 B 的文档模式;先读 `openspec/PRD/<runId>/.session-state.json`(`docModeOnly: true` 时严禁写业务代码)与 `03-待确认问题清单.md`。
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
30
|
+
- **默认自动串联**(`openspec/PRD/<runId>/.session-state.json` 中 `autoChainOpsx !== false`):
|
|
31
|
+
- 阶段 B 产出 01/02/闭环03/04 后,**同一回合**继续阶段 C→D,**不要**停下来问「是否继续」。
|
|
32
|
+
- 读 `06-opsx-自动串联指引.md` 与 `.cursor/skills/opsx-superpowers-workflow/SKILL.md`。
|
|
33
|
+
- **仅当用户说**「只提案」「先别写代码」时:阶段 C 结束后停止,等待 `/opsx-apply`。
|
|
34
|
+
- 口语仍可用:`继续` / `做提案` / `开始 apply`(用于断点续跑)。
|
|
33
35
|
|
|
34
36
|
## 触发后执行规则(分阶段,禁止跳步)
|
|
35
37
|
|
|
@@ -41,7 +43,10 @@ description: 开发某版本需求、SDD 流程、待确认问题澄清、用户
|
|
|
41
43
|
---
|
|
42
44
|
|
|
43
45
|
### 阶段 A — Step1 + Step2(必须可停顿)
|
|
44
|
-
1. **Step1
|
|
46
|
+
1. **Step1**:打开 `01-获取需求文档指引.md`,**在终端执行其中的 `confluence-doc.py` 命令**(不要跳过)。
|
|
47
|
+
- ADI 必须用 `docs/adi-doc-skill` 或上级 `../docs/adi-doc-skill`,**禁止**用 `docs/oms-doc-skill`。
|
|
48
|
+
- 用户说「开发 ADI_V2.3.2」时,脚本参数传 `2.3.2` 或 `ADI_V2.3.2` 均可。
|
|
49
|
+
- 将下载的 markdown 回填 `source/PRD.md`。
|
|
45
50
|
2. **Step2**:执行 `02-需求分析提示词.md`(引用 `docs/` 下已下载的 PRD)
|
|
46
51
|
|
|
47
52
|
**门禁(Step2)**
|
|
@@ -61,7 +66,9 @@ description: 开发某版本需求、SDD 流程、待确认问题澄清、用户
|
|
|
61
66
|
2. 将状态改为 **矛盾点池状态:已闭合**。
|
|
62
67
|
3. 再生成 `01`、`02`、`04`(路径均在 `openspec/PRD/<runId>/`)。
|
|
63
68
|
4. **本回合禁止**:`StrReplace`/`Write` 任何业务源码;禁止「顺手实现一版」。
|
|
64
|
-
5.
|
|
69
|
+
5. 更新 `.session-state.json`:`status: ready_for_propose`,`phase: B_done`。
|
|
70
|
+
6. **若 `autoChainOpsx !== false` 且用户未说「只提案」**:**不要结束回合**——立即打开 `06-opsx-自动串联指引.md` 执行阶段 C→D(见下文)。
|
|
71
|
+
7. 若 `autoChainOpsx === false`:简短说明 01/02/03/04 已就绪,等待用户说「继续」。
|
|
65
72
|
|
|
66
73
|
**本阶段只允许产出:**
|
|
67
74
|
1. 更新 `03-待确认问题清单.md`(标注 **矛盾点池状态:已闭合**,写入用户确认结论)
|
|
@@ -71,34 +78,34 @@ description: 开发某版本需求、SDD 流程、待确认问题澄清、用户
|
|
|
71
78
|
|
|
72
79
|
**本阶段禁止:**
|
|
73
80
|
- 修改 `src/`、`pages/`、`components/`、`api/` 等实现代码(`02-改动点清单` 只是**计划**,不是实施许可)
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
|
|
77
|
-
完成后告知用户:“01/02/03/04 已就绪。发 UI 参考路径,或回复「继续」我将执行 /opsx-propose。”
|
|
81
|
+
- 在 **未闭合 03** 时执行 `/opsx-propose` 或 `/opsx-apply`
|
|
82
|
+
- 在 `autoChainOpsx === false` 时未经用户同意就进入阶段 C/D
|
|
78
83
|
|
|
79
84
|
---
|
|
80
85
|
|
|
81
|
-
### 阶段 C — `/opsx-propose
|
|
82
|
-
|
|
86
|
+
### 阶段 C — `/opsx-propose`(生成提案,对应 Step5)
|
|
87
|
+
**触发条件**:
|
|
88
|
+
- 阶段 B 已完成(03 已闭合,01/02/04 已生成),**且**
|
|
89
|
+
- `autoChainOpsx !== false`(默认)→ **与用户确认同一回合内自动执行**;或用户说「继续/做提案」;或 `status` 为 `ready_for_propose` 且用户未说「只提案」。
|
|
83
90
|
|
|
84
91
|
**必须执行:**
|
|
85
92
|
1. 先读当前仓库 `.opsx/config.json` 与本地 OPSX skills(禁止跨项目 fallback)
|
|
86
|
-
2. 执行 **`/opsx-propose
|
|
87
|
-
- `01-需求分析报告.md`
|
|
88
|
-
- `02-改动点清单.md`
|
|
89
|
-
- **UI
|
|
93
|
+
2. 执行 **`/opsx-propose`**(遵循 `.cursor/commands/opsx-propose.md` 与 `openspec-propose` skill),输入至少包含:
|
|
94
|
+
- `openspec/PRD/<runId>/01-需求分析报告.md`
|
|
95
|
+
- `openspec/PRD/<runId>/02-改动点清单.md`
|
|
96
|
+
- **UI 图/交互参考**(按 `06-opsx-自动串联指引.md` 解析;无则注明缺失并继续)
|
|
90
97
|
3. 一次性生成 openspec change(`proposal.md`、`design.md`、`specs/`、`tasks.md` 等)
|
|
98
|
+
4. 更新 `05-openspec-提案草稿.md`;`.session-state.json` → `status: propose_done`
|
|
91
99
|
|
|
92
|
-
**本阶段交付物 = openspec
|
|
93
|
-
|
|
100
|
+
**本阶段交付物 = openspec 提案产物**。
|
|
101
|
+
**本阶段禁止**:写 `src/`、跑 delivery-pipeline。
|
|
94
102
|
|
|
95
|
-
|
|
96
|
-
- 执行 `/opsx-apply`(留给阶段 D)
|
|
103
|
+
**默认**:提案 apply-ready 后 **立即** 进入阶段 D(勿问用户「是否 apply」),除非用户说了「只提案」。
|
|
97
104
|
|
|
98
105
|
---
|
|
99
106
|
|
|
100
107
|
### 阶段 D — Apply + TDD + Playwright 交付(禁止裸写代码收尾)
|
|
101
|
-
**触发条件**:阶段 C 的 openspec 提案已 apply-ready
|
|
108
|
+
**触发条件**:阶段 C 的 openspec 提案已 apply-ready(`autoChainOpsx` 默认下与阶段 C **同一连续执行**)。
|
|
102
109
|
|
|
103
110
|
**严禁**:跳过 skill 链、不按 `tasks.md` TDD、不跑 delivery-pipeline 就改 `src/` 并宣称完成。
|
|
104
111
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# 06 OPSX 自动串联(阶段 B 完成后立即执行)
|
|
2
|
+
|
|
3
|
+
> **不要**在阶段 B 结束后停下来问用户「是否继续」或「是否 apply」。
|
|
4
|
+
> 除非用户明确说「只提案」「先别写代码」,或 `.session-state.json` 中 `autoChainOpsx: false`。
|
|
5
|
+
|
|
6
|
+
## 输入(本 runId)
|
|
7
|
+
| 文件 | 路径 |
|
|
8
|
+
|------|------|
|
|
9
|
+
| 需求分析 | `01-需求分析报告.md` |
|
|
10
|
+
| 改动清单 | `02-改动点清单.md` |
|
|
11
|
+
| 技术草稿 | `04-技术文档草稿.md` |
|
|
12
|
+
| UI 参考 | 见下方「UI 路径解析」 |
|
|
13
|
+
|
|
14
|
+
## UI 路径解析(按优先级)
|
|
15
|
+
1. 用户本条消息给出的路径
|
|
16
|
+
2. `source/PRD.md` 中的「原型图片目录」行(如 `docs/adi-v232/`)
|
|
17
|
+
3. `docs/<product>-v<version无点>/` 或 `docs/<product>-<version>/` 目录(只读扫描)
|
|
18
|
+
|
|
19
|
+
缺失 UI 时:**仍继续** propose,在 `proposal.md` / `design.md` 注明「UI 待补」。
|
|
20
|
+
|
|
21
|
+
## 阶段 C — `/opsx-propose`(同一对话回合内)
|
|
22
|
+
1. 读仓库根 `.cursor/skills/opsx-superpowers-workflow/SKILL.md` 与 `openspec-propose/SKILL.md`
|
|
23
|
+
2. 执行 `/opsx-propose`(或等价的 openspec CLI 全流程),输入 = 上表 01 + 02 + UI
|
|
24
|
+
3. change 名建议:`{{PRODUCT}}-v{{VERSION}}-<short-topic>`(kebab-case)
|
|
25
|
+
4. 更新 `05-openspec-提案草稿.md` 与 `.session-state.json`:`status: propose_done`
|
|
26
|
+
5. **禁止**在本阶段写 `src/` 或跑 delivery-pipeline
|
|
27
|
+
|
|
28
|
+
## 阶段 D — `/opsx-apply`(提案 apply-ready 后**立即**执行,无需再等用户)
|
|
29
|
+
1. `npx sdd-flow-kit ensure-opsx --agent cursor -y`(仓库根)
|
|
30
|
+
2. 写 `.opsx-active-change` = change 名
|
|
31
|
+
3. 读并执行:`openspec-apply-change` → `tdd-script` → `openspec-superpowers-pipeline` → `opsx-dev-delivery-pipeline`
|
|
32
|
+
4. `pnpm run opsx:delivery-pipeline -- <change-name>` 直至 **exit 0**
|
|
33
|
+
5. 更新 `.session-state.json`:`status: complete`,`docModeOnly: false`
|
|
34
|
+
|
|
35
|
+
## 完成判定
|
|
36
|
+
- [ ] `openspec/changes/<name>/` 下 applyRequires 全部 done
|
|
37
|
+
- [ ] `tasks.md` 全部 `- [x]`
|
|
38
|
+
- [ ] delivery-pipeline exit 0
|
|
@@ -86,8 +86,10 @@
|
|
|
86
86
|
1. 更新 `03-待确认问题清单.md`,标注“**矛盾点池状态:已闭合**”,写入用户确认结论。
|
|
87
87
|
2. 生成最终版 `01-需求分析报告.md` 和 `02-改动点清单.md`(清单中的路径是**计划**,不得在本回合修改这些文件)。
|
|
88
88
|
3. 确认 `02-改动点清单.md` 中仅包含代码文件改动,不涉及 `openspec/spec/` 内容。
|
|
89
|
-
4. **禁止**
|
|
90
|
-
5.
|
|
89
|
+
4. **禁止** 在阶段 B 内修改 `src/` 等业务代码。
|
|
90
|
+
5. 更新 `.session-state.json`:`status: ready_for_propose`,`phase: B_done`。
|
|
91
|
+
6. **若 `autoChainOpsx !== false`(默认)且用户未说「只提案」**:同一回合继续读 `06-opsx-自动串联指引.md`,执行 `/opsx-propose`(输入 01+02+UI)→ `/opsx-apply`;**不要**要求用户再说「继续」。
|
|
92
|
+
7. 仅当 `autoChainOpsx === false` 时输出:`01/02/03/04 已更新。回复「继续」进入提案。`
|
|
91
93
|
|
|
92
94
|
---
|
|
93
95
|
|
|
@@ -32,10 +32,15 @@ alwaysApply: true
|
|
|
32
32
|
1. 定位最新或对话相关的 `openspec/PRD/<runId>/`(读 `RUNME.md` 或 `.session-state.json`)。
|
|
33
33
|
2. **仅**更新 `03`(写入用户结论,标为已闭合)。
|
|
34
34
|
3. 再生成/更新 `01`、`02`、`04`。
|
|
35
|
-
4.
|
|
36
|
-
5.
|
|
35
|
+
4. 生成 `01`、`02`、`04` 后更新 `.session-state.json`:`status: ready_for_propose`,`phase: B_done`。
|
|
36
|
+
5. **默认自动串联**(`autoChainOpsx !== false`,且用户未说「只提案」「先别写代码」):
|
|
37
|
+
- **同一回合**读 `06-opsx-自动串联指引.md`,执行 `/opsx-propose` → `/opsx-apply`。
|
|
38
|
+
- UI 参考优先:`source/PRD.md` 中的原型目录(如 `docs/adi-v232/`),其次用户本条消息给出的路径。
|
|
39
|
+
- **不要**停下来问「是否继续」或「是否 apply」。
|
|
40
|
+
6. 仅当 `autoChainOpsx === false` 时:文档更新后停止,等用户说「继续」再 propose。
|
|
37
41
|
|
|
38
42
|
## 唯一允许写业务代码的时机
|
|
39
|
-
-
|
|
43
|
+
- 阶段 D:`/opsx-propose` 已完成且 change **apply-ready** 后执行 `/opsx-apply`(SDD 默认与用户确认后**自动**进入,无需再等「开始 apply」)。
|
|
44
|
+
- 用户明确「只提案」时:在 apply-ready 之前**禁止**写业务代码。
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
路径 B(03 已闭合、01/02/04 已生成)中:**禁止**仅更新文档就结束回合而不进入 propose(除非 `autoChainOpsx === false` 或用户说只提案)。
|
|
@@ -5,7 +5,7 @@ import sys
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from urllib.parse import urljoin, urlparse, unquote
|
|
8
|
+
from urllib.parse import quote, urljoin, urlparse, unquote
|
|
9
9
|
|
|
10
10
|
try:
|
|
11
11
|
from playwright.sync_api import sync_playwright
|
|
@@ -24,6 +24,11 @@ def env(name: str, default: str | None = None) -> str:
|
|
|
24
24
|
return str(v).strip()
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def env_bool(name: str, default: bool = False) -> bool:
|
|
28
|
+
v = env(name, "1" if default else "0").lower()
|
|
29
|
+
return v in ("1", "true", "yes", "on")
|
|
30
|
+
|
|
31
|
+
|
|
27
32
|
def load_env_file(file_path: Path) -> None:
|
|
28
33
|
"""轻量加载 .env 文件(不依赖 python-dotenv)。"""
|
|
29
34
|
if not file_path.exists() or not file_path.is_file():
|
|
@@ -57,11 +62,13 @@ def version_slug(prefix: str, version: str) -> str:
|
|
|
57
62
|
|
|
58
63
|
|
|
59
64
|
def search_keywords(prefix: str, version: str) -> list[str]:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
"""固定顺序(与用户 Confluence 全局搜索习惯一致)。无论用户输入 ADI 2.3.2 还是 ADI_V2.3.2 都走这一套。"""
|
|
66
|
+
return [
|
|
67
|
+
f"{prefix}_V{version}",
|
|
68
|
+
f"{prefix}-V{version}",
|
|
69
|
+
f"{prefix}_{version}",
|
|
70
|
+
f"{prefix}-{version}",
|
|
71
|
+
]
|
|
65
72
|
|
|
66
73
|
|
|
67
74
|
def sanitize_filename(name: str) -> str:
|
|
@@ -116,23 +123,54 @@ def href_path_matches_keyword(href: str, keyword: str) -> bool:
|
|
|
116
123
|
return kw in path or compact in path_compact
|
|
117
124
|
|
|
118
125
|
|
|
119
|
-
def
|
|
120
|
-
|
|
126
|
+
def score_link_match(text: str, href: str, prefix: str, version: str, keyword: str) -> int:
|
|
127
|
+
"""对搜索结果链接打分;>=2 视为命中。"""
|
|
128
|
+
t = (text or "").lower()
|
|
129
|
+
path = unquote(urlparse(href).path).lower()
|
|
130
|
+
kw = keyword.lower()
|
|
131
|
+
p = prefix.lower()
|
|
132
|
+
v_compact = version.replace(".", "")
|
|
133
|
+
t_compact = re.sub(r"\s+", "", t)
|
|
134
|
+
path_compact = path.replace(".", "").replace("-", "").replace("_", "")
|
|
135
|
+
|
|
136
|
+
score = 0
|
|
137
|
+
if kw in t:
|
|
138
|
+
score += 4
|
|
139
|
+
if kw.replace(".", "") in t_compact:
|
|
140
|
+
score += 3
|
|
141
|
+
if p in t and version in t:
|
|
142
|
+
score += 4
|
|
143
|
+
if p in t and v_compact in t_compact:
|
|
144
|
+
score += 3
|
|
145
|
+
if href_path_matches_keyword(href, keyword):
|
|
146
|
+
score += 3
|
|
147
|
+
slug = f"{p}v{v_compact}"
|
|
148
|
+
if slug in path_compact or f"{p}-v{v_compact}" in path or f"{p}_v{v_compact}" in path:
|
|
149
|
+
score += 4
|
|
150
|
+
return score
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def collect_search_candidates(
|
|
154
|
+
page, base_url: str, prefix: str, version: str, keyword: str
|
|
155
|
+
) -> list[tuple[str, str, int]]:
|
|
156
|
+
"""返回 [(url, title, score), ...]"""
|
|
121
157
|
seen: set[str] = set()
|
|
158
|
+
scored: list[tuple[int, str, str]] = []
|
|
159
|
+
|
|
122
160
|
selectors = [
|
|
123
161
|
".search-result .title a",
|
|
124
162
|
".search-result-title a",
|
|
125
163
|
"#search-results .gs-title a",
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
"
|
|
164
|
+
".search-results-panel a",
|
|
165
|
+
"a[data-linked-resource-id]",
|
|
166
|
+
"a[href*='/pages/']",
|
|
167
|
+
"a[href*='/display/']",
|
|
130
168
|
]
|
|
131
|
-
scored: list[tuple[int, str]] = []
|
|
132
169
|
|
|
133
170
|
for selector in selectors:
|
|
134
171
|
links = page.locator(selector)
|
|
135
|
-
|
|
172
|
+
count = min(links.count(), 200)
|
|
173
|
+
for i in range(count):
|
|
136
174
|
href = links.nth(i).get_attribute("href") or ""
|
|
137
175
|
raw_text = (links.nth(i).inner_text() or "").strip()
|
|
138
176
|
title_text = raw_text.split("\n")[0].strip()
|
|
@@ -145,23 +183,66 @@ def find_search_result(page, base_url: str, keyword: str):
|
|
|
145
183
|
continue
|
|
146
184
|
seen.add(full_url)
|
|
147
185
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
continue
|
|
152
|
-
|
|
153
|
-
score = 2 + (1 if path_hit else 0)
|
|
154
|
-
scored.append((score, full_url))
|
|
186
|
+
s = score_link_match(text, href, prefix, version, keyword)
|
|
187
|
+
if s >= 2:
|
|
188
|
+
scored.append((s, full_url, text[:120]))
|
|
155
189
|
|
|
156
|
-
if not scored:
|
|
157
|
-
return None
|
|
158
190
|
scored.sort(key=lambda x: x[0], reverse=True)
|
|
159
|
-
|
|
160
|
-
print(f" 找到文档链接: {best[:100]}...")
|
|
161
|
-
return best
|
|
191
|
+
return [(u, t, s) for s, u, t in scored]
|
|
162
192
|
|
|
163
193
|
|
|
164
|
-
def
|
|
194
|
+
def read_breadcrumb_text(page) -> str:
|
|
195
|
+
parts: list[str] = []
|
|
196
|
+
for selector in [
|
|
197
|
+
"#breadcrumbs li",
|
|
198
|
+
"#breadcrumbs a",
|
|
199
|
+
"ol.aui-nav-breadcrumbs li",
|
|
200
|
+
".breadcrumbs li",
|
|
201
|
+
"nav[aria-label='Breadcrumbs'] li",
|
|
202
|
+
]:
|
|
203
|
+
loc = page.locator(selector)
|
|
204
|
+
for i in range(min(loc.count(), 20)):
|
|
205
|
+
txt = (loc.nth(i).inner_text() or "").strip()
|
|
206
|
+
if txt and txt not in parts:
|
|
207
|
+
parts.append(txt)
|
|
208
|
+
return " > ".join(parts)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def is_prd_page(page, page_url: str) -> bool:
|
|
212
|
+
"""检查页面面包屑/父级是否属于 PRD(需求文档)。"""
|
|
213
|
+
page.goto(page_url, wait_until="networkidle", timeout=60000)
|
|
214
|
+
page.wait_for_timeout(1200)
|
|
215
|
+
|
|
216
|
+
crumbs = read_breadcrumb_text(page)
|
|
217
|
+
crumbs_upper = crumbs.upper()
|
|
218
|
+
if re.search(r"\bPRD\b", crumbs_upper) or "需求" in crumbs or "PRD" in crumbs_upper:
|
|
219
|
+
print(f" ✓ PRD 父级命中: {crumbs[:120]}")
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
title = (page.locator("#title-text").inner_text() or "").strip()
|
|
224
|
+
except Exception:
|
|
225
|
+
title = ""
|
|
226
|
+
title_upper = title.upper()
|
|
227
|
+
if re.search(r"\bPRD\b", title_upper) or "需求文档" in title:
|
|
228
|
+
print(f" ✓ 标题含 PRD/需求: {title[:80]}")
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
print(f" ✗ 非 PRD 父级: {crumbs[:120] or title[:80]}")
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run_search_dosearch(page, base_url: str, prefix: str, version: str, keyword: str) -> list[tuple[str, str, int]]:
|
|
236
|
+
url = f"{base_url.rstrip('/')}/dosearchsite.action?queryString={quote(keyword)}"
|
|
237
|
+
print(f" → 全站搜索: {url}")
|
|
238
|
+
page.goto(url, wait_until="networkidle", timeout=60000)
|
|
239
|
+
page.wait_for_timeout(2500)
|
|
240
|
+
if is_login_page(page):
|
|
241
|
+
return []
|
|
242
|
+
return collect_search_candidates(page, base_url, prefix, version, keyword)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def run_search_quick(page, base_url: str, prefix: str, version: str, keyword: str) -> list[tuple[str, str, int]]:
|
|
165
246
|
search_selector = None
|
|
166
247
|
for selector in [
|
|
167
248
|
"#quick-search-query",
|
|
@@ -176,14 +257,92 @@ def run_search(page, base_url: str, keyword: str) -> str | None:
|
|
|
176
257
|
search_selector = selector
|
|
177
258
|
break
|
|
178
259
|
if not search_selector:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
search_selector = "input[type='text']"
|
|
260
|
+
return []
|
|
261
|
+
print(f" → 顶栏搜索框: {search_selector} 关键词: {keyword}")
|
|
182
262
|
page.fill(search_selector, keyword, timeout=8000)
|
|
183
263
|
page.keyboard.press("Enter")
|
|
184
264
|
page.wait_for_load_state("networkidle", timeout=30000)
|
|
185
|
-
page.wait_for_timeout(
|
|
186
|
-
return
|
|
265
|
+
page.wait_for_timeout(2500)
|
|
266
|
+
return collect_search_candidates(page, base_url, prefix, version, keyword)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def resolve_prd_target(
|
|
270
|
+
page, base_url: str, prefix: str, version: str, keywords: list[str]
|
|
271
|
+
) -> tuple[str | None, str | None, str]:
|
|
272
|
+
"""依次用每个关键词搜索,直到找到父级为 PRD 的页面。"""
|
|
273
|
+
seen_urls: set[str] = set()
|
|
274
|
+
fallback: tuple[str, str, str] | None = None # url, title, keyword
|
|
275
|
+
|
|
276
|
+
for kw in keywords:
|
|
277
|
+
print(f"\n🔍 搜索关键词: {kw}")
|
|
278
|
+
batches = [
|
|
279
|
+
("dosearchsite", run_search_dosearch(page, base_url, prefix, version, kw)),
|
|
280
|
+
("quick-search", run_search_quick(page, base_url, prefix, version, kw)),
|
|
281
|
+
]
|
|
282
|
+
for method_name, candidates in batches:
|
|
283
|
+
if not candidates:
|
|
284
|
+
print(f" ({method_name}) 无结果")
|
|
285
|
+
continue
|
|
286
|
+
print(f" ({method_name}) {len(candidates)} 条候选,检查 PRD 父级...")
|
|
287
|
+
for url, title, _score in candidates:
|
|
288
|
+
if url in seen_urls:
|
|
289
|
+
continue
|
|
290
|
+
seen_urls.add(url)
|
|
291
|
+
print(f" · 候选: {title[:70]}")
|
|
292
|
+
if is_prd_page(page, url):
|
|
293
|
+
return url, kw, title
|
|
294
|
+
if fallback is None:
|
|
295
|
+
fallback = (url, kw, title)
|
|
296
|
+
|
|
297
|
+
if fallback:
|
|
298
|
+
url, kw, title = fallback
|
|
299
|
+
print(f" ⚠ 未找到 PRD 父级,回退使用: {title[:70]}")
|
|
300
|
+
return url, kw, title
|
|
301
|
+
return None, None, ""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def is_login_page(page) -> bool:
|
|
305
|
+
url = page.url.lower()
|
|
306
|
+
if "login.action" in url or "/login" in url:
|
|
307
|
+
return True
|
|
308
|
+
if page.locator("#login-form, form#login, #username-field").count() > 0:
|
|
309
|
+
if page.locator("#login-button, #login").count() > 0:
|
|
310
|
+
return True
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def ensure_logged_in(page, base_url: str, username: str, password: str) -> None:
|
|
315
|
+
login_url = f"{base_url.rstrip('/')}/login.action"
|
|
316
|
+
page.goto(login_url, wait_until="networkidle", timeout=60000)
|
|
317
|
+
if not is_login_page(page):
|
|
318
|
+
print("✅ 已处于登录状态")
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
if not username or not password:
|
|
322
|
+
raise RuntimeError("需要登录但未提供 CONFLUENCE_USERNAME / CONFLUENCE_PASSWORD")
|
|
323
|
+
|
|
324
|
+
print("🔐 正在登录 Confluence...")
|
|
325
|
+
page.wait_for_selector("#username-field, input[name='os_username']", timeout=15000)
|
|
326
|
+
user_sel = "#username-field" if page.locator("#username-field").count() else "input[name='os_username']"
|
|
327
|
+
pass_sel = "#password-field" if page.locator("#password-field").count() else "input[name='os_password']"
|
|
328
|
+
btn_sel = "#login-button" if page.locator("#login-button").count() else "#login"
|
|
329
|
+
page.fill(user_sel, username, timeout=8000)
|
|
330
|
+
page.fill(pass_sel, password, timeout=8000)
|
|
331
|
+
page.click(btn_sel)
|
|
332
|
+
page.wait_for_load_state("networkidle", timeout=45000)
|
|
333
|
+
page.wait_for_timeout(1500)
|
|
334
|
+
|
|
335
|
+
if is_login_page(page):
|
|
336
|
+
err = ""
|
|
337
|
+
for sel in [".aui-message.error", ".aui-message.aui-message-error", "#login-error"]:
|
|
338
|
+
loc = page.locator(sel)
|
|
339
|
+
if loc.count():
|
|
340
|
+
err = (loc.first.inner_text() or "").strip()
|
|
341
|
+
break
|
|
342
|
+
raise RuntimeError(
|
|
343
|
+
f"Confluence 登录失败(仍在登录页)。请检查账号密码或 .env 中的 CONFLUENCE_* 配置。{err}"
|
|
344
|
+
)
|
|
345
|
+
print("✅ 登录成功")
|
|
187
346
|
|
|
188
347
|
|
|
189
348
|
def main():
|
|
@@ -192,10 +351,15 @@ def main():
|
|
|
192
351
|
print(" 用法: python3 confluence-doc.py <版本号>")
|
|
193
352
|
sys.exit(1)
|
|
194
353
|
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
354
|
+
# 从脚本目录向上查找 .env(兼容 adInsight-web/frontend 子包)
|
|
355
|
+
script_dir = Path(__file__).resolve().parent
|
|
356
|
+
cur = script_dir
|
|
357
|
+
for _ in range(8):
|
|
358
|
+
for env_name in [".env.pre", ".env", ".env.local", ".env.development", ".env.test"]:
|
|
359
|
+
load_env_file(cur / env_name)
|
|
360
|
+
if cur.parent == cur:
|
|
361
|
+
break
|
|
362
|
+
cur = cur.parent
|
|
199
363
|
|
|
200
364
|
base_url = env("CONFLUENCE_BASE_URL", "https://confluence.huan.tv") or "https://confluence.huan.tv"
|
|
201
365
|
search_url = env("CONFLUENCE_SEARCH_URL") or f"{base_url.rstrip('/')}/#all-updates"
|
|
@@ -229,8 +393,21 @@ def main():
|
|
|
229
393
|
print(f"📁 输出目录: {docs_root}")
|
|
230
394
|
print(f"🖼️ 图片目录: {version_dir}")
|
|
231
395
|
|
|
396
|
+
headed = env_bool("CONFLUENCE_HEADED") or env("CONFLUENCE_HEADLESS", "1").lower() in (
|
|
397
|
+
"0",
|
|
398
|
+
"false",
|
|
399
|
+
"no",
|
|
400
|
+
)
|
|
401
|
+
headless = not headed
|
|
402
|
+
slow_mo = int(env("CONFLUENCE_SLOW_MO", "400" if headed else "0") or "0")
|
|
403
|
+
|
|
404
|
+
print(f"🖥️ 浏览器: headless={headless}, slow_mo={slow_mo}ms(有头模式: CONFLUENCE_HEADED=1)")
|
|
405
|
+
|
|
232
406
|
with sync_playwright() as p:
|
|
233
|
-
|
|
407
|
+
launch_opts: dict = {"headless": headless}
|
|
408
|
+
if slow_mo > 0:
|
|
409
|
+
launch_opts["slow_mo"] = slow_mo
|
|
410
|
+
browser = p.chromium.launch(**launch_opts)
|
|
234
411
|
context = browser.new_context(
|
|
235
412
|
user_agent=(
|
|
236
413
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
@@ -239,44 +416,44 @@ def main():
|
|
|
239
416
|
)
|
|
240
417
|
page = context.new_page()
|
|
241
418
|
try:
|
|
242
|
-
print("\n📄
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
print("✅ 登录完成")
|
|
256
|
-
|
|
257
|
-
target_url = None
|
|
258
|
-
matched_keyword = None
|
|
259
|
-
for kw in keywords:
|
|
260
|
-
print(f"\n🔍 搜索关键词: {kw}")
|
|
261
|
-
found = run_search(page, base_url, kw)
|
|
262
|
-
if found:
|
|
263
|
-
target_url = found
|
|
264
|
-
matched_keyword = kw
|
|
265
|
-
break
|
|
266
|
-
print(f" 未找到 {kw},尝试下一个关键词...")
|
|
419
|
+
print("\n📄 打开 Confluence 并登录...")
|
|
420
|
+
ensure_logged_in(page, base_url, username, password)
|
|
421
|
+
print(f"📄 当前页: {page.url[:120]}")
|
|
422
|
+
|
|
423
|
+
direct_url = env("CONFLUENCE_PAGE_URL")
|
|
424
|
+
target_url = direct_url if direct_url else None
|
|
425
|
+
matched_keyword = "CONFLUENCE_PAGE_URL" if direct_url else None
|
|
426
|
+
|
|
427
|
+
matched_title = ""
|
|
428
|
+
if not target_url:
|
|
429
|
+
target_url, matched_keyword, matched_title = resolve_prd_target(
|
|
430
|
+
page, base_url, prefix, version, keywords
|
|
431
|
+
)
|
|
267
432
|
|
|
268
433
|
if not target_url or not matched_keyword:
|
|
269
|
-
|
|
434
|
+
debug_path = docs_root / "confluence-search-debug.png"
|
|
435
|
+
try:
|
|
436
|
+
page.screenshot(path=str(debug_path), full_page=True)
|
|
437
|
+
print(f" 已保存调试截图: {debug_path}")
|
|
438
|
+
except Exception:
|
|
439
|
+
pass
|
|
440
|
+
print(f"❌ 未找到 {prefix} {version} 的需求文档。")
|
|
441
|
+
print(
|
|
442
|
+
" 排查:1) 是否存在 PRD 父级页面 2) 依次尝试 ADI_V2.3.2 / ADI-V2.3.2 / ADI_2.3.2 / ADI-2.3.2"
|
|
443
|
+
)
|
|
444
|
+
print(" 3) 或设置 CONFLUENCE_PAGE_URL=<页面完整URL> 直接下载")
|
|
270
445
|
sys.exit(1)
|
|
271
446
|
|
|
272
|
-
print(f"✅
|
|
273
|
-
|
|
447
|
+
print(f"✅ 命中关键词: {matched_keyword}")
|
|
448
|
+
if matched_title:
|
|
449
|
+
print(f" 页面: {matched_title[:100]}")
|
|
450
|
+
print(f" URL: {target_url[:120]}...")
|
|
274
451
|
page.goto(target_url, wait_until="networkidle", timeout=60000)
|
|
275
452
|
|
|
276
453
|
try:
|
|
277
454
|
title = page.locator("#title-text").text_content().strip()
|
|
278
455
|
except Exception:
|
|
279
|
-
title = f"{matched_keyword} 需求文档"
|
|
456
|
+
title = matched_title or f"{matched_keyword} 需求文档"
|
|
280
457
|
|
|
281
458
|
content_elem = page.locator("#main-content")
|
|
282
459
|
if not content_elem.count():
|
|
@@ -331,6 +508,11 @@ def main():
|
|
|
331
508
|
print(f"\n❌ 获取失败: {e}")
|
|
332
509
|
raise
|
|
333
510
|
finally:
|
|
511
|
+
if env_bool("CONFLUENCE_DEBUG_PAUSE", default=not headless):
|
|
512
|
+
try:
|
|
513
|
+
input("\n⏸ 调试暂停:按 Enter 关闭浏览器...")
|
|
514
|
+
except (EOFError, KeyboardInterrupt):
|
|
515
|
+
pass
|
|
334
516
|
browser.close()
|
|
335
517
|
|
|
336
518
|
|
|
@@ -27,6 +27,12 @@ python3 confluence-doc.py <版本号>
|
|
|
27
27
|
|
|
28
28
|
说明:脚本内置默认账号密码(`lixinxin` / `Xin@147258`),可直接执行;如需覆盖,再设置 `CONFLUENCE_USERNAME` / `CONFLUENCE_PASSWORD`。
|
|
29
29
|
|
|
30
|
+
有头调试(可观察登录与搜索过程):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
CONFLUENCE_HEADED=1 CONFLUENCE_SLOW_MO=500 CONFLUENCE_DEBUG_PAUSE=1 python3 confluence-doc.py ADI_V2.3.2
|
|
34
|
+
```
|
|
35
|
+
|
|
30
36
|
版本号支持多种写法:
|
|
31
37
|
|
|
32
38
|
```bash
|
|
@@ -36,11 +42,14 @@ python3 confluence-doc.py {{PRODUCT_PREFIX}}-V1.2.3
|
|
|
36
42
|
python3 confluence-doc.py {{PRODUCT_PREFIX}}_V1.2.3
|
|
37
43
|
```
|
|
38
44
|
|
|
39
|
-
|
|
45
|
+
脚本搜索顺序(**固定**,即使用户说「{{PRODUCT_PREFIX}} 1.2.3」也按此顺序):
|
|
46
|
+
|
|
47
|
+
1. `{{PRODUCT_PREFIX}}_V<版本>`(如 ADI_V2.3.2)
|
|
48
|
+
2. `{{PRODUCT_PREFIX}}-V<版本>`(如 ADI-V2.3.2)
|
|
49
|
+
3. `{{PRODUCT_PREFIX}}_<版本>`(如 ADI_2.3.2)
|
|
50
|
+
4. `{{PRODUCT_PREFIX}}-<版本>`(如 ADI-2.3.2)
|
|
40
51
|
|
|
41
|
-
|
|
42
|
-
2. `{{PRODUCT_PREFIX}}_V<版本>`
|
|
43
|
-
3. `V<版本>`(兜底)
|
|
52
|
+
每个关键词都会走全站搜索 + 顶栏搜索;在候选结果中**优先选面包屑父级含 PRD 的页面**。
|
|
44
53
|
|
|
45
54
|
## 输出结构
|
|
46
55
|
|