@xulthekl/team-flow 0.36.4 → 0.38.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.
Files changed (50) hide show
  1. package/.claude/always/phase-guard.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor-plugin/marketplace.json +1 -1
  6. package/.cursor-plugin/plugin.json +1 -1
  7. package/.github/plugin/marketplace.json +2 -2
  8. package/AGENTS.md +1 -0
  9. package/CHANGELOG.md +37 -0
  10. package/GEMINI.md +1 -1
  11. package/INSTALL.md +1 -1
  12. package/README.md +1 -1
  13. package/agents/architecture-reviewer.md +16 -6
  14. package/agents/change-split-auditor.md +11 -6
  15. package/agents/code-reviewer.md +7 -6
  16. package/agents/cross-change-consistency-checker.md +11 -6
  17. package/agents/prd-completeness-reviewer.md +11 -6
  18. package/agents/prototype-reviewer.md +11 -6
  19. package/docs/README_en.md +1 -1
  20. package/docs/solutions/INDEX.md +1 -0
  21. package/docs/solutions/cross-phase/2026-08-06-no-summary.md +17 -0
  22. package/gemini-extension.json +1 -1
  23. package/hooks/session-start +2 -2
  24. package/llms.txt +1 -1
  25. package/package.json +1 -1
  26. package/plugin.json +1 -1
  27. package/scripts/lib/cmd-prototype.mjs +227 -0
  28. package/scripts/lib/cmd-publish.mjs +254 -0
  29. package/scripts/lib/prototype-sync.mjs +41 -2
  30. package/scripts/lib/test-matrix-export.mjs +5 -0
  31. package/scripts/lib/test-merge.mjs +130 -93
  32. package/scripts/team-flow.mjs +8 -0
  33. package/skills/architecture-design/references/s3.5-product-architecture.md +9 -0
  34. package/skills/build-executor/SKILL.md +7 -0
  35. package/skills/build-executor/implementer-prompt.md +3 -1
  36. package/skills/ce-brainstorm/references/prototype-loop.md +2 -2
  37. package/skills/code-reviewer/SKILL.md +4 -3
  38. package/skills/code-reviewer/code-reviewer-prompt.md +2 -2
  39. package/skills/contract-builder/SKILL.md +19 -4
  40. package/skills/e2e/SKILL.md +2 -0
  41. package/skills/prototype/SKILL.md +4 -3
  42. package/skills/prototype/references/orchestration-flow.md +2 -2
  43. package/skills/release-archivist/SKILL.md +25 -6
  44. package/skills/spec-writer/SKILL.md +12 -8
  45. package/skills/test-strategy/SKILL.md +2 -1
  46. package/skills/test-strategy/references/design-methods-detail.md +1 -0
  47. package/skills/test-strategy/references/test-quality-rules.md +1 -0
  48. package/skills/workflow-orchestrator/references/s2-prd-prototype-loop.md +11 -2
  49. package/skills/workflow-orchestrator/references/s4-split-validate.md +9 -0
  50. package/skills/workflow-start/SKILL.md +10 -1
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * prototype — 原型仓库版本 worktree 隔离(v0.37.0 §68.4)
4
+ *
5
+ * 用法:
6
+ * tf prototype branch <prd-vN> # 创建/复用原型版本 worktree(隔离 + 团队拉取)
7
+ * tf prototype deisolate <prd-vN> [--merge] [--clean]
8
+ * # 版本收尾:merge 回主干 + 清理 worktree
9
+ *
10
+ * 设计(v0.15 §68.4/§68.6):
11
+ * - 原型为独立 git 仓库(有远程),位置约定 = <workspace>/prototype/
12
+ * - 隔离单位 = PRD 版本(分支 `prd-vN`),worktree 布局 = <workspace>/.worktrees/prd-vN/prototype/
13
+ * (与代码实施 ensure-branch 的 .worktrees/ 统一隔离区同构,gitignored)
14
+ * - branch:幂等(已有 worktree 复用);从远程拉 prd-vN(远程存在)或从主干创建(不存在)
15
+ * —— worktree 命令同时是团队查看者的一键拉取入口(§68.6)
16
+ * - deisolate:版本收尾(change_dag 该版本全部 closing)时 merge `prd-vN` 回主干 + 清理
17
+ * (方案 A,LT 决策 2026-08-05:merge 时机 = 版本收尾,一次 merge 干净)
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process';
21
+ import { existsSync, readdirSync, rmSync, appendFileSync, readFileSync } from 'node:fs';
22
+ import { join, resolve, basename } from 'node:path';
23
+
24
+ const PROTECTED = ['main', 'master'];
25
+
26
+ /** git 执行封装(统一 encoding + 错误降级)。 */
27
+ function git(cwd, ...args) {
28
+ return execFileSync('git', args, { encoding: 'utf-8', cwd, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
29
+ }
30
+
31
+ /** 规范化 PRD 版本参数 → `prd-vN`。接受 "v2"/"2"/"prd-v2"。 */
32
+ function normalizeBranchName(raw) {
33
+ const cleaned = String(raw || '').trim();
34
+ if (!cleaned) throw new Error('缺少 PRD 版本参数(如 tf prototype branch v2)');
35
+ if (/^prd-v\d+/i.test(cleaned)) return cleaned.toLowerCase();
36
+ const v = cleaned.replace(/^v/i, '');
37
+ if (!/^\d+$/.test(v)) throw new Error(`非法 PRD 版本:${raw}(应为 vN,如 v2)`);
38
+ return `prd-v${v}`;
39
+ }
40
+
41
+ /** 定位 workspace root(cwd 在主仓库内)与原型仓库路径。 */
42
+ function resolveLayout() {
43
+ let workspaceRoot;
44
+ try {
45
+ workspaceRoot = git(process.cwd(), 'rev-parse', '--show-toplevel');
46
+ } catch {
47
+ throw new Error('无法定位 workspace root:cwd 不在 git 仓库内?');
48
+ }
49
+ const protoRepo = join(workspaceRoot, 'prototype');
50
+ if (!existsSync(join(protoRepo, '.git'))) {
51
+ throw new Error(`原型仓库不存在或非 git 仓库:${protoRepo}\n 约定:原型仓库 = <workspace>/prototype/(独立 git 仓库,有远程)`);
52
+ }
53
+ return { workspaceRoot, protoRepo };
54
+ }
55
+
56
+ function ensureWorktreeGitignore(root) {
57
+ const gitignorePath = join(root, '.gitignore');
58
+ let existing = '';
59
+ try { existing = readFileSync(gitignorePath, 'utf-8'); } catch { existing = ''; }
60
+ const already = existing.split(/\r?\n/).map(l => l.trim()).some(l => l === '.worktrees/' || l === '.worktrees');
61
+ if (already) return;
62
+ appendFileSync(gitignorePath, `${existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''}.worktrees/\n`);
63
+ console.log(`prototype: added '.worktrees/' to ${gitignorePath}.`);
64
+ }
65
+
66
+ /** 检查远程 prd-vN 分支是否存在。 */
67
+ function remoteBranchExists(protoRepo, branch) {
68
+ try {
69
+ git(protoRepo, 'rev-parse', '--verify', `origin/${branch}`);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /** 检查本地 prd-vN 分支是否存在。 */
77
+ function localBranchExists(protoRepo, branch) {
78
+ try {
79
+ git(protoRepo, 'rev-parse', '--verify', `refs/heads/${branch}`);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /** 子命令 branch:创建/复用原型版本 worktree。 */
87
+ function cmdBranch(branchName) {
88
+ const { workspaceRoot, protoRepo } = resolveLayout();
89
+ const wtPath = join(workspaceRoot, '.worktrees', branchName, basename(protoRepo));
90
+
91
+ // 幂等:worktree 已存在 → 复用
92
+ if (existsSync(join(wtPath, '.git'))) {
93
+ console.log(`prototype: worktree 已存在,复用 ${wtPath}`);
94
+ console.log(` 分支:${branchName}(原型版本 ${branchName} 的工作区)`);
95
+ return { created: false, reused: true, worktreePath: wtPath };
96
+ }
97
+
98
+ ensureWorktreeGitignore(workspaceRoot);
99
+
100
+ // fetch 远程,获取团队已推送的 prd-vN
101
+ try { git(protoRepo, 'fetch', 'origin'); } catch { /* 远程不可达时降级为本地创建 */ }
102
+
103
+ const fromRemote = remoteBranchExists(protoRepo, branchName);
104
+ try {
105
+ if (fromRemote) {
106
+ git(protoRepo, 'worktree', 'add', wtPath, '-b', branchName, `origin/${branchName}`);
107
+ console.log(`prototype: 从远程 origin/${branchName} 创建 worktree ${wtPath}`);
108
+ } else {
109
+ git(protoRepo, 'worktree', 'add', wtPath, '-b', branchName);
110
+ console.log(`prototype: 从主干创建分支 ${branchName} 的 worktree ${wtPath}`);
111
+ console.log(` 提示:该版本原型首次创建,迭代完成后 push origin ${branchName} 供团队拉取`);
112
+ }
113
+ return { created: true, fromRemote, worktreePath: wtPath };
114
+ } catch (e) {
115
+ const msg = (e.stderr || e.stdout || e.message || '').toString().trim();
116
+ throw new Error(`worktree 创建失败:${msg}`);
117
+ }
118
+ }
119
+
120
+ /** 子命令 deisolate:版本收尾 merge 回主干 + 清理 worktree。 */
121
+ function cmdDeisolate(branchName, opts) {
122
+ const { workspaceRoot, protoRepo } = resolveLayout();
123
+ const wtPath = join(workspaceRoot, '.worktrees', branchName, basename(protoRepo));
124
+ const result = { branch: branchName, worktreePath: wtPath };
125
+
126
+ if (!existsSync(join(wtPath, '.git'))) {
127
+ console.log(`prototype: ${branchName} 无 worktree(${wtPath} 不存在),无可清理。`);
128
+ return { found: false };
129
+ }
130
+ result.found = true;
131
+
132
+ const masterBranch = git(protoRepo, 'branch', '--show-current') || 'master';
133
+ result.master = masterBranch;
134
+
135
+ // ahead/behind 摘要
136
+ try {
137
+ const ahead = git(protoRepo, 'rev-list', '--count', `${masterBranch}..${branchName}`);
138
+ const behind = git(protoRepo, 'rev-list', '--count', `${branchName}..${masterBranch}`);
139
+ result.ahead = parseInt(ahead, 10);
140
+ result.behind = parseInt(behind, 10);
141
+ } catch { /* 分支可能未合并过,忽略 */ }
142
+
143
+ if (opts.merge) {
144
+ try {
145
+ git(protoRepo, 'merge', branchName, '--no-edit');
146
+ result.merged = true;
147
+ console.log(`✅ ${branchName} merged → ${masterBranch}(版本收尾,方案 A)`);
148
+ } catch (e) {
149
+ result.merged = false;
150
+ result.mergeError = (e.stderr || e.stdout || e.message || '').toString().trim();
151
+ console.error(`❌ merge 失败:${result.mergeError}`);
152
+ console.error(' 存在冲突时请先人工解决再重试。');
153
+ }
154
+ } else {
155
+ console.log(` ahead=${result.ahead ?? '?'} behind=${result.behind ?? '?'}(相对 ${masterBranch})`);
156
+ console.log(' 未执行 merge(加 --merge 提交合并回主干)。');
157
+ }
158
+
159
+ if (opts.clean) {
160
+ const errors = [];
161
+ try { git(protoRepo, 'worktree', 'remove', wtPath, '--force'); result.worktreeRemoved = true; }
162
+ catch {
163
+ try { rmSync(wtPath, { recursive: true, force: true }); result.worktreeRemoved = true; }
164
+ catch (e) { errors.push(`worktree remove: ${e.message}`); }
165
+ }
166
+ try { git(protoRepo, 'branch', '-d', branchName); result.branchDeleted = true; }
167
+ catch {
168
+ try { git(protoRepo, 'branch', '-D', branchName); result.branchDeleted = true; }
169
+ catch (e) { errors.push(`branch delete: ${e.message}`); }
170
+ }
171
+ if (errors.length > 0) console.error(` ⚠️ ${errors.join('; ')}`);
172
+ else console.log('✅ worktree + 分支已清理。');
173
+ } else {
174
+ console.log(' 未执行清理(加 --clean 删除 worktree + 分支)。');
175
+ }
176
+
177
+ return result;
178
+ }
179
+
180
+ function usage() {
181
+ console.log(`tf prototype — 原型仓库版本 worktree 隔离(v0.37.0 §68.4)
182
+
183
+ 用法:
184
+ tf prototype branch <prd-vN> # 创建/复用原型版本 worktree(隔离 + 团队拉取)
185
+ tf prototype deisolate <prd-vN> [--merge] [--clean]
186
+ # 版本收尾:merge 回主干 + 清理 worktree
187
+
188
+ 示例:
189
+ tf prototype branch v2 # 为 PRD v2 创建/复用 prd-v2 worktree
190
+ tf prototype branch prd-v2 # 同上(分支名已含前缀)
191
+ tf prototype deisolate v2 --merge --clean # v2 收尾:merge 回主干 + 清理
192
+
193
+ 说明:
194
+ - 原型仓库约定:<workspace>/prototype/(独立 git 仓库,有远程)
195
+ - worktree 布局:<workspace>/.worktrees/<prd-vN>/prototype/(gitignored 隔离区)
196
+ - merge 时机 = 版本收尾(change_dag 该版本全部 closing),一次 merge 回主干`);
197
+ }
198
+
199
+ /** CLI 入口(export 供 team-flow.mjs 注册)。 */
200
+ export async function run(args = []) {
201
+ const [sub, rawVersion, ...rest] = args;
202
+ if (!sub || sub === '-h' || sub === '--help') { usage(); return; }
203
+
204
+ if (!rawVersion) {
205
+ console.error(`tf prototype ${sub} 缺少 PRD 版本参数(如 tf prototype ${sub} v2)`);
206
+ process.exit(2);
207
+ }
208
+
209
+ let branchName;
210
+ try { branchName = normalizeBranchName(rawVersion); }
211
+ catch (e) { console.error(e.message); process.exit(2); }
212
+
213
+ try {
214
+ if (sub === 'branch') {
215
+ return cmdBranch(branchName);
216
+ }
217
+ if (sub === 'deisolate') {
218
+ const opts = { merge: rest.includes('--merge'), clean: rest.includes('--clean') };
219
+ return cmdDeisolate(branchName, opts);
220
+ }
221
+ console.error(`未知子命令:${sub}(可用:branch / deisolate)`);
222
+ process.exit(2);
223
+ } catch (e) {
224
+ console.error(`prototype: ${e.message}`);
225
+ process.exit(1);
226
+ }
227
+ }
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * publish — 阶段产物白名单提交 + 可选推送(v0.37.0 §68.3)
4
+ *
5
+ * 用法:tf publish <target> [--push] [--dry-run]
6
+ * target: --prd(PRD + orchestrator.yaml + registry.yaml + 主仓库 prototype/)
7
+ * / --arch(docs/architecture/ 迭代快照)
8
+ * / --changes <dir>(change 制品,缺省则整个 changes/)
9
+ * / --all(全部)
10
+ *
11
+ * 设计(v0.15 §68.3):
12
+ * 1. 白名单 git add:只 add 本次 target 对应目录,防卷走无关改动(复用 arch-merge 协作安全模式)
13
+ * 2. 脏文件检测:target 目录之外存在未提交文件 → 告警(不阻断,提示人工确认)
14
+ * 3. 写锁:.publish.lock(mkdir 原子语义,防并行 publish 冲突)
15
+ * 4. push 确认制:--push 显式开启;只推当前分支、不跨分支(push 是外向操作,强制确认)
16
+ * 5. --dry-run:只打印将执行的 git 动作,不落盘不 commit 不 push
17
+ */
18
+
19
+ import { execFileSync } from 'node:child_process';
20
+ import { existsSync, mkdirSync, readdirSync, rmdirSync } from 'node:fs';
21
+ import { join, resolve, isAbsolute } from 'node:path';
22
+
23
+ const LOCK_PATH = '.publish.lock';
24
+
25
+ /** 解析 CLI 参数。 */
26
+ function parseArgv(argv) {
27
+ const parsed = { targets: [], push: false, dryRun: false, changeDir: null };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ if (a === '--push') parsed.push = true;
31
+ else if (a === '--dry-run') parsed.dryRun = true;
32
+ else if (a === '--prd') parsed.targets.push('prd');
33
+ else if (a === '--arch') parsed.targets.push('arch');
34
+ else if (a === '--changes') {
35
+ // 无目录 = 整个 changes/;带目录 = 单个 change(目录以 -- 开头则视为无目录,MAJOR 修复 v0.37.0)
36
+ parsed.targets.push('changes');
37
+ const next = argv[i + 1];
38
+ if (next && !next.startsWith('--')) parsed.changeDir = argv[++i];
39
+ } else if (a === '--all') parsed.targets.push('all');
40
+ else if (a === '-h' || a === '--help') parsed.help = true;
41
+ else {
42
+ console.error(`Unknown argument: ${a}`);
43
+ console.error('Usage: tf publish <--prd|--arch|--changes <dir>|--all> [--push] [--dry-run]');
44
+ process.exit(2);
45
+ }
46
+ }
47
+ if (parsed.targets.length === 0 && !parsed.help) {
48
+ console.error('No target specified.');
49
+ console.error('Usage: tf publish <--prd|--arch|--changes <dir>|--all> [--push] [--dry-run]');
50
+ process.exit(2);
51
+ }
52
+ return parsed;
53
+ }
54
+
55
+ /** git 执行封装(统一 encoding + 错误降级)。 */
56
+ function git(cwd, ...args) {
57
+ return execFileSync('git', args, { encoding: 'utf-8', cwd, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
58
+ }
59
+
60
+ function isDir(p) {
61
+ try { return existsSync(p) && readdirSync(p).length >= 0; } catch { return false; }
62
+ }
63
+
64
+ /**
65
+ * 收集本次 publish 的白名单路径(目录级)。
66
+ * 只 add 这些路径;target 目录之外的未提交改动一律不碰。
67
+ */
68
+ function collectWhitelist(projectRoot, targets, changeDir) {
69
+ const paths = [];
70
+ const want = t => targets.includes('all') || targets.includes(t);
71
+
72
+ if (want('prd')) {
73
+ const prdDir = join(projectRoot, 'prd');
74
+ if (isDir(prdDir)) paths.push(prdDir);
75
+ // 主仓库内 prototype/(独立仓库场景由原型 worktree 命令管理,这里仅收集主仓库可见部分)
76
+ const protoDir = join(projectRoot, 'prototype');
77
+ if (isDir(protoDir)) paths.push(protoDir);
78
+ // 产品级编排状态
79
+ const teamflow = join(projectRoot, '.team-flow');
80
+ if (isDir(teamflow)) {
81
+ const registry = join(teamflow, 'registry.yaml');
82
+ if (existsSync(registry)) paths.push(registry);
83
+ const reqDir = join(teamflow, 'requirements');
84
+ if (isDir(reqDir)) {
85
+ for (const req of readdirSync(reqDir)) {
86
+ const oy = join(reqDir, req, 'orchestrator.yaml');
87
+ if (existsSync(oy)) paths.push(oy);
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ if (want('arch')) {
94
+ const archDir = join(projectRoot, 'docs', 'architecture');
95
+ if (isDir(archDir)) paths.push(archDir);
96
+ // arch_baseline 豁免键(v0.37.0 §68.5):随架构快照一并同步,否则团队拉取后门禁误判存量(IMPORTANT 修复)
97
+ const archState = join(projectRoot, '.team-flow', 'arch-state.json');
98
+ if (existsSync(archState)) paths.push(archState);
99
+ }
100
+
101
+ if (want('changes')) {
102
+ if (changeDir) {
103
+ const abs = isAbsolute(changeDir) ? changeDir : resolve(projectRoot, changeDir);
104
+ if (existsSync(abs)) paths.push(abs);
105
+ else console.warn(`[WARN] change 目录不存在:${abs}`);
106
+ } else {
107
+ const changesDir = join(projectRoot, 'changes');
108
+ if (isDir(changesDir)) paths.push(changesDir);
109
+ }
110
+ }
111
+
112
+ return paths;
113
+ }
114
+
115
+ /**
116
+ * 检测白名单之外的未提交/未跟踪文件(团队协作安全底线,仿 arch-merge detectUntouchedDirtyFiles)。
117
+ * 返回值:外部脏文件列表(含 staged + unstaged + untracked)。
118
+ */
119
+ function detectOutsideDirty(projectRoot, whitelist) {
120
+ const relWhitelist = new Set(whitelist.map(p => {
121
+ const rel = p.replace(projectRoot + '/', '');
122
+ return rel.replace(/\/+$/, '');
123
+ }));
124
+ try {
125
+ const status = git(projectRoot, 'status', '--porcelain');
126
+ if (!status) return [];
127
+ return status.split('\n').filter(Boolean)
128
+ .map(line => {
129
+ // porcelain 前 2 字符是状态,后面是路径(引号包裹的可能带空格)
130
+ const raw = line.slice(3).replace(/^"|"$/g, '').replace(/\\ /g, ' ');
131
+ return raw;
132
+ })
133
+ .filter(p => {
134
+ if (!p) return false;
135
+ const norm = p.replace(/\/+$/, ''); // porcelain 目录级输出带尾斜杠,先去规范化
136
+ // 跳过 .publish.lock 自身(写锁目录)
137
+ if (norm === LOCK_PATH || norm.startsWith(LOCK_PATH + '/')) return false;
138
+ // 跳过 worktree 隔离区(gitignored,理论上不会出现,防御性跳过)
139
+ if (norm.startsWith('.worktrees/')) return false;
140
+ // 白名单判定(双向往返匹配):
141
+ // - norm 在白名单项内(norm === w 或 norm.startsWith(w + '/'))→ 豁免
142
+ // - 白名单项是 norm 的后代(w.startsWith(norm + '/'))→ 豁免
143
+ // (全新仓库 porcelain 对未跟踪内容显示目录级如 `?? docs/`,
144
+ // 其下白名单子目录 docs/architecture 已被覆盖,属正常提交范围)
145
+ for (const w of relWhitelist) {
146
+ if (norm === w || norm.startsWith(w + '/')) return false;
147
+ if (w.startsWith(norm + '/')) return false;
148
+ }
149
+ return true;
150
+ });
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ function acquireLock(projectRoot) {
157
+ const lockPath = join(projectRoot, LOCK_PATH);
158
+ if (existsSync(lockPath)) {
159
+ throw new Error(`another publish in progress (${LOCK_PATH} exists) — 串行化提交,稍后重试`);
160
+ }
161
+ mkdirSync(lockPath);
162
+ return lockPath;
163
+ }
164
+
165
+ function releaseLock(lockPath) {
166
+ try { rmdirSync(lockPath); } catch { /* ignore */ }
167
+ }
168
+
169
+ function describeTargets(parsed) {
170
+ const t = parsed.targets.includes('all') ? 'all' : parsed.targets.join('+');
171
+ return parsed.targets.includes('changes') && parsed.changeDir ? `${t}:${parsed.changeDir}` : t;
172
+ }
173
+
174
+ /**
175
+ * CLI 入口(export 供 team-flow.mjs 注册)。
176
+ */
177
+ export async function run(args = []) {
178
+ const parsed = parseArgv(args);
179
+ if (parsed.help) {
180
+ console.log(`tf publish — 阶段产物白名单提交 + 可选推送(v0.37.0 §68.3)
181
+
182
+ 用法:tf publish <target> [--push] [--dry-run]
183
+ --prd 提交 PRD + orchestrator.yaml + registry.yaml + 主仓库 prototype/
184
+ --arch 提交 docs/architecture/(迭代快照)
185
+ --changes 提交整个 changes/(或 --changes <change-dir> 指定单个 change)
186
+ --all 提交全部上述
187
+ --push 同时推送到远程当前分支(默认只提交不推送)
188
+ --dry-run 只打印将执行的 git 动作,不落盘不 commit`);
189
+ return;
190
+ }
191
+
192
+ const projectRoot = git(process.cwd(), 'rev-parse', '--show-toplevel');
193
+ const whitelist = collectWhitelist(projectRoot, parsed.targets, parsed.changeDir);
194
+
195
+ if (whitelist.length === 0) {
196
+ console.log('ℹ️ 没有可提交的阶段产物(白名单为空)。');
197
+ return;
198
+ }
199
+
200
+ // 脏文件检测(target 之外)→ 告警不阻断
201
+ const outsideDirty = detectOutsideDirty(projectRoot, whitelist);
202
+ if (outsideDirty.length > 0) {
203
+ console.warn(`[WARN] 白名单之外存在 ${outsideDirty.length} 个未提交文件(不提交):`);
204
+ for (const f of outsideDirty.slice(0, 5)) console.warn(` - ${f}`);
205
+ if (outsideDirty.length > 5) console.warn(` ... 等 ${outsideDirty.length} 个`);
206
+ }
207
+
208
+ const targetDesc = describeTargets(parsed);
209
+ const commitMsg = `tf publish: ${targetDesc} — sync stage artifacts`;
210
+
211
+ if (parsed.dryRun) {
212
+ console.log('[DRY-RUN] Would execute:');
213
+ console.log(` git add ${whitelist.map(p => p.replace(projectRoot + '/', '')).join(' ')}`);
214
+ console.log(` git commit -m "${commitMsg}"`);
215
+ if (parsed.push) {
216
+ const branch = git(projectRoot, 'branch', '--show-current');
217
+ console.log(` git push origin ${branch}`);
218
+ }
219
+ return { published: false, dryRun: true };
220
+ }
221
+
222
+ const lockPath = acquireLock(projectRoot);
223
+ try {
224
+ // 白名单 git add(只 add 目标目录)
225
+ for (const p of whitelist) {
226
+ const rel = p.replace(projectRoot + '/', '');
227
+ execFileSync('git', ['add', rel], { cwd: projectRoot, stdio: 'pipe' });
228
+ }
229
+ // commit(nothing to commit 可接受)
230
+ try {
231
+ execFileSync('git', ['commit', '-m', commitMsg], { cwd: projectRoot, stdio: 'pipe' });
232
+ console.log(`✅ committed: ${commitMsg}`);
233
+ } catch (e) {
234
+ // git 空提交消息在 stdout(execFileSync 失败时 stderr 常为空 Buffer,需先排除空值)
235
+ const msg = (e.stderr && e.stderr.toString()) || (e.stdout && e.stdout.toString()) || '';
236
+ // git 空提交两种提示:工作树干净 / 有未跟踪但无 staged 变更
237
+ if (msg.includes('nothing to commit') || msg.includes('nothing added to commit')) {
238
+ console.log('ℹ️ 没有变更需要提交。');
239
+ } else {
240
+ throw new Error(`git commit failed: ${msg}`);
241
+ }
242
+ }
243
+ // push(确认制)
244
+ if (parsed.push) {
245
+ const branch = git(projectRoot, 'branch', '--show-current');
246
+ console.log(`↗️ pushing to origin/${branch} ...`);
247
+ execFileSync('git', ['push', 'origin', branch], { cwd: projectRoot, stdio: 'inherit' });
248
+ console.log('✅ pushed.');
249
+ }
250
+ return { published: true, targets: parsed.targets, pushed: parsed.push };
251
+ } finally {
252
+ releaseLock(lockPath);
253
+ }
254
+ }
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { readFileSync, writeFileSync, existsSync, cpSync, mkdirSync } from 'node:fs';
18
- import { join, basename, relative } from 'node:path';
18
+ import { join, basename, relative, sep, resolve } from 'node:path';
19
19
 
20
20
  /**
21
21
  * 解析 CLI 参数数组为结构化对象
@@ -36,6 +36,45 @@ function parseArgv(argv) {
36
36
  return parsed;
37
37
  }
38
38
 
39
+ /**
40
+ * 推导 change 所属 PRD 版本(v0.37.0 §68.5)。
41
+ * 优先级:change-brief.md 的 upstream_plan_ref → change 目录名 v{N}- 前缀。
42
+ * @returns {string|null} 如 'v2';无法推导返回 null
43
+ */
44
+ function derivePrdVersion(changeDir) {
45
+ const briefPath = join(changeDir, 'change-brief.md');
46
+ try {
47
+ if (existsSync(briefPath)) {
48
+ const content = readFileSync(briefPath, 'utf-8');
49
+ const m = content.match(/upstream_plan_ref:\s*prd\/(v\d+)\//);
50
+ if (m) return m[1];
51
+ }
52
+ } catch { /* ignore */ }
53
+ const m = basename(changeDir).match(/^v(\d+)-/);
54
+ if (m) return `v${m[1]}`;
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * 解析原型目录(v0.37.0 §68.5 制品链路径版本化):
60
+ * --prototype-dir 显式指定优先;否则从 change 推导 PRD 版本 → 定位版本 worktree 的 prototype/。
61
+ * worktree 不存在时回退主仓库 prototype/ 并提示。
62
+ */
63
+ function resolvePrototypeDir(changeDir, prototypeDirArg) {
64
+ if (prototypeDirArg) return prototypeDirArg;
65
+ const version = derivePrdVersion(changeDir);
66
+ if (!version) return 'prototype'; // 无法推导版本 → 回退主仓库 prototype/
67
+ const abs = resolve(changeDir);
68
+ const changesIdx = abs.lastIndexOf(sep + 'changes' + sep);
69
+ const workspaceRoot = changesIdx > 0 ? abs.slice(0, changesIdx) : process.cwd();
70
+ const branchName = `prd-${version}`;
71
+ const wt = join(workspaceRoot, '.worktrees', branchName, 'prototype');
72
+ if (existsSync(join(wt, '.git'))) return wt;
73
+ const fallback = join(workspaceRoot, 'prototype');
74
+ console.log(`ℹ️ 原型版本 worktree 不存在(${wt}),回退 ${fallback}。原型独立仓库场景请先 \`tf prototype branch ${branchName}\`。`);
75
+ return fallback;
76
+ }
77
+
39
78
  /**
40
79
  * 执行 prototype-sync 合并
41
80
  * @param {string[]|object} args - CLI 参数数组(从 tf 框架传入)或已解析的对象
@@ -49,7 +88,7 @@ export function run(args = {}) {
49
88
 
50
89
  const changeDir = args._?.[0] || args.changeDir;
51
90
  const sourcePath = args.source;
52
- const prototypeDir = args.prototypeDir || 'prototype';
91
+ const prototypeDir = resolvePrototypeDir(changeDir, args.prototypeDir);
53
92
 
54
93
  if (!changeDir) {
55
94
  console.error('Usage: tf prototype-sync <change-dir> [--source <ux-delta-path>] [--prototype-dir <path>]');
@@ -54,6 +54,9 @@ const TEST_KIND_TO_TIER = {
54
54
  redis_social: 'integration',
55
55
  external_api_stub: 'integration',
56
56
  test_infrastructure: 'integration',
57
+ // e2e tier (v0.38.0,对齐 e2e skill 的 prototype/integration 两 project)
58
+ playwright_prototype: 'e2e',
59
+ playwright_integration: 'e2e',
57
60
  };
58
61
 
59
62
  /**
@@ -120,6 +123,7 @@ export function convert(matrix, options = {}) {
120
123
  // 统计
121
124
  const unitCases = cases.filter(c => deriveTestTier(c.test_kind) === 'unit');
122
125
  const integrationCases = cases.filter(c => deriveTestTier(c.test_kind) === 'integration');
126
+ const e2eCases = cases.filter(c => deriveTestTier(c.test_kind) === 'e2e');
123
127
 
124
128
  // 提取 complexity
125
129
  const complexity = matrix.target_complexity || {};
@@ -141,6 +145,7 @@ export function convert(matrix, options = {}) {
141
145
  lines.push(`- Modules covered: 1 (${moduleName}, complexity: ${complexityTier})`);
142
146
  lines.push(`- Unit cases: ${unitCases.length} (${Math.round(unitCases.length / cases.length * 100) || 0}%)`);
143
147
  lines.push(`- Integration cases: ${integrationCases.length} (${Math.round(integrationCases.length / cases.length * 100) || 0}%)`);
148
+ lines.push(`- E2E cases: ${e2eCases.length} (${Math.round(e2eCases.length / cases.length * 100) || 0}%)`);
144
149
  lines.push(`- Deferred items: ${candidateLedger.filter(c => c.decision === 'deferred').length}`);
145
150
  lines.push(`- Matrix revision: 1`);
146
151
  lines.push('');