@xulthekl/team-flow 0.34.1 → 0.36.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 (49) 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/CHANGELOG.md +57 -0
  9. package/GEMINI.md +1 -1
  10. package/INSTALL.md +1 -1
  11. package/README.md +1 -1
  12. package/agents/architecture-reviewer.md +12 -0
  13. package/agents/build-executor.md +23 -0
  14. package/docs/README_en.md +1 -1
  15. package/docs/solutions/INDEX.md +1 -0
  16. package/docs/solutions/cross-phase/2026-08-05-no-summary.md +17 -0
  17. package/gemini-extension.json +1 -1
  18. package/hooks/session-start +2 -2
  19. package/llms.txt +1 -1
  20. package/package.json +1 -1
  21. package/plugin.json +1 -1
  22. package/scripts/guard/checks/arch-gate-exemptions.mjs +68 -0
  23. package/scripts/guard/checks/arch-readiness.mjs +36 -0
  24. package/scripts/guard/checks/arch-snapshot.mjs +35 -0
  25. package/scripts/guard/guard.mjs +10 -2
  26. package/scripts/lib/arch-merge.mjs +405 -316
  27. package/scripts/lib/arch-parse.mjs +162 -0
  28. package/scripts/lib/cmd-arch.mjs +84 -0
  29. package/scripts/lib/cmd-deisolate.mjs +292 -0
  30. package/scripts/lib/cmd-execution.mjs +16 -3
  31. package/scripts/lib/execution-plan.mjs +37 -5
  32. package/scripts/lib/test-merge.mjs +7 -4
  33. package/scripts/team-flow.mjs +7 -0
  34. package/skills/architecture-design/SKILL.md +22 -1
  35. package/skills/architecture-design/chapters/ch06-integration.md +4 -4
  36. package/skills/architecture-design/references/s3.5-architecture-template.md +164 -0
  37. package/skills/architecture-design/references/s3.5-loading-protocol.md +40 -0
  38. package/skills/architecture-design/references/s3.5-product-architecture.md +76 -0
  39. package/skills/release-archivist/SKILL.md +14 -0
  40. package/skills/session-handoff/references/handoff-template.md +2 -2
  41. package/skills/spec-writer/SKILL.md +1 -0
  42. package/skills/workflow-bootstrap/references/agents/arch-reverse-analyst.md +60 -0
  43. package/skills/workflow-orchestrator/references/feedback-loops.md +2 -0
  44. package/skills/workflow-orchestrator/references/s1-path-router.md +1 -1
  45. package/skills/workflow-orchestrator/references/s3-plan-pipeline.md +2 -1
  46. package/skills/workflow-orchestrator/references/s4-split-validate.md +11 -1
  47. package/skills/workflow-orchestrator/references/state-model.md +52 -0
  48. package/skills/workflow-start/SKILL.md +1 -0
  49. package/skills/workflow-start/references/routing-rules.md +1 -0
@@ -0,0 +1,162 @@
1
+ // scripts/lib/arch-parse.mjs — 结构化解析 change 架构产物(v0.35.0,v0.14 §62.2)
2
+ //
3
+ // P2 机器管结构:从 markdown 表格 / schema SQL 提取机器可读数据,替代正则扫 markdown。
4
+ // 背景(红队评估实证):旧实现正则硬编码(`## 2. To-Be`、`/api/` 前缀、反引号表名)
5
+ // 在 LLM 文档标题漂移/格式变化时静默失败(match 失配返回空串、流程继续、返回 merged:true)。
6
+ // 本模块用通用表格解析(按 `|` split + 去反引号)与格式容错匹配,抽取失配由调用方 abort。
7
+
8
+ /** 解析一行 markdown 表格为单元格数组(去反引号/去 code 包裹/trim)。非表格行返回 null。 */
9
+ export function parseTableRow(line) {
10
+ const trimmed = line.trim();
11
+ if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) return null;
12
+ return trimmed
13
+ .slice(1, -1)
14
+ .split('|')
15
+ .map(cell => cell.trim().replace(/`/g, '').trim());
16
+ }
17
+
18
+ /**
19
+ * 在内容中定位某个标题(headingRe),解析其后的第一个表格为行数组。
20
+ * 标题匹配容错:不依赖序号/中英文(如 `/^#{2,3}\s*\d*\.?\s*(聚合注册表|Aggregate Registry)/i`)。
21
+ * 返回行数组(每行 = 单元格数组);找不到标题或表格返回 []。
22
+ */
23
+ export function parseTableAfter(content, headingRe) {
24
+ const lines = content.split('\n');
25
+ let i = 0;
26
+ for (; i < lines.length; i++) {
27
+ if (headingRe.test(lines[i])) break;
28
+ }
29
+ if (i >= lines.length) return [];
30
+ for (; i < lines.length; i++) {
31
+ if (lines[i].trim().startsWith('|')) break;
32
+ }
33
+ if (i >= lines.length) return [];
34
+ const rows = [];
35
+ for (; i < lines.length; i++) {
36
+ const line = lines[i];
37
+ if (!line.trim().startsWith('|')) break;
38
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
39
+ const cells = parseTableRow(line);
40
+ if (cells && cells.length > 0) rows.push(cells);
41
+ }
42
+ return rows;
43
+ }
44
+
45
+ /**
46
+ * 从 api.md 提取端点(Command/Read/Query 三段表格)。
47
+ * 容错:匹配路径单元格(`/xxx/{id}`)与方法单元格(GET/POST/PUT/DELETE/PATCH),
48
+ * 不依赖 `/api/` 前缀硬编码(企业系统可能是 /v1/、/gateway/oms/ 等)。
49
+ */
50
+ export function extractEndpoints(apiMd) {
51
+ const endpoints = [];
52
+ const lines = apiMd.split('\n');
53
+ let currentKind = 'unknown';
54
+ for (const line of lines) {
55
+ if (/^#{2,3}\s*.*(Command|命令)/i.test(line)) currentKind = 'Command';
56
+ else if (/^#{2,3}\s*.*(Read|读取)/i.test(line)) currentKind = 'Read';
57
+ else if (/^#{2,3}\s*.*(Query|查询)/i.test(line)) currentKind = 'Query';
58
+ if (!line.trim().startsWith('|')) continue;
59
+ const cells = parseTableRow(line);
60
+ if (!cells || cells.length === 0) continue;
61
+ const pathCell = cells.find(c => /^\/[\w\-/{}.]+$/.test(c));
62
+ if (!pathCell) continue;
63
+ const methodCell = cells.find(c => /^(GET|POST|PUT|DELETE|PATCH)$/i.test(c));
64
+ // kind:章节标题优先;若无章节标题(如 API-INDEX 生成格式),从表格分流列取
65
+ const kindCell = cells.find(c => /^(Command|Read|Query)$/i.test(c));
66
+ const kind = currentKind !== 'unknown' ? currentKind : (kindCell || 'unknown');
67
+ endpoints.push({ path: pathCell, method: methodCell ? methodCell.toUpperCase() : '', kind });
68
+ }
69
+ return endpoints;
70
+ }
71
+
72
+ /**
73
+ * 从 architecture.md 提取聚合清单。聚合 id 格式 `context:Aggregate`。
74
+ * 兼容两种模板:
75
+ * - 产品级「聚合注册表」:`| 聚合ID | 上下文 | 根实体 | ... |`(列1=上下文,列2=根,列5=不变量)
76
+ * - change 级「To-Be 增量设计 §2.1 聚合变更」:`| 聚合名称 | 操作 | 聚合根 | ... | 所属 BC |`(列1=操作,列5=所属 BC)
77
+ */
78
+ export function extractAggregates(archMd) {
79
+ let rows = parseTableAfter(archMd, /^#{2,3}\s*\d*\.?\s*(聚合注册表|Aggregate Registry)/i);
80
+ if (rows.length === 0) {
81
+ rows = parseTableAfter(archMd, /^#{3,4}\s*\d*\.?\s*(聚合变更|Aggregate Changes)/i);
82
+ }
83
+ if (rows.length === 0) {
84
+ rows = parseTableAfter(archMd, /^#{2,3}\s*\d*\.?\s*To-Be/i);
85
+ }
86
+ const aggregates = [];
87
+ for (const row of rows) {
88
+ const id = row[0]?.trim();
89
+ if (!id || id === '聚合ID' || id === '聚合名称' || id === 'Aggregate') continue;
90
+ if (!/^[\w]+:[\w]+$/.test(id)) continue;
91
+ const col1 = row[1]?.trim() || '';
92
+ const isChange = /^(新增|修改|New|Update)/i.test(col1);
93
+ // source:产品级全局格式(buildCurrentStateSection 生成)列3=来源 changeName;change 级无来源列 → ''
94
+ const source = /^change:/.test(row[3]?.trim() || '') ? row[3].trim() : '';
95
+ aggregates.push({
96
+ id,
97
+ context: isChange ? (row[5]?.trim() || '') : col1,
98
+ root: row[2]?.trim() || '',
99
+ invariants: isChange ? '' : (row[5]?.trim() || ''),
100
+ source,
101
+ });
102
+ }
103
+ return aggregates;
104
+ }
105
+
106
+ /** 从 schema-baseline.sql 提取 CREATE TABLE 表名(容错:IF NOT EXISTS / 反引号 / schema 前缀)。 */
107
+ export function extractTablesFromSql(sql) {
108
+ const tables = [];
109
+ const re = /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([\w.]+)`?\s*(?:\(|;)/gi;
110
+ let m;
111
+ while ((m = re.exec(sql))) {
112
+ tables.push(m[1].replace(/`/g, ''));
113
+ }
114
+ return [...new Set(tables)];
115
+ }
116
+
117
+ /**
118
+ * 从演进日志段提取条目。返回 `{ key, content }[]`,key 用 changeName 唯一锚
119
+ *(`### change:<name>` 或 `### <name>`),替代按版本号去重(不同 change 可能同版本号)。
120
+ */
121
+ export function extractEvolutionLogEntries(archMd, changeName) {
122
+ const logStart = archMd.search(/^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m);
123
+ if (logStart < 0) return [];
124
+ const logSection = archMd.slice(logStart);
125
+ const entries = [];
126
+ const blockRe = /(###\s+.+)([\s\S]*?)(?=###\s|##\s|\n#\s|$)/g;
127
+ let m;
128
+ while ((m = blockRe.exec(logSection))) {
129
+ const title = m[1].trim();
130
+ const body = m[2].trim();
131
+ if (!title || !body) continue;
132
+ entries.push({ key: changeName, title, body });
133
+ }
134
+ return entries;
135
+ }
136
+
137
+ /** 从 database.md 的 Schema 变更详情段(§3)提取受影响的表。 */
138
+ export function extractTablesFromDatabaseMd(dbMd) {
139
+ const tables = new Set();
140
+ // 匹配表格行中的 CREATE/ALTER TABLE 操作列
141
+ const re = /(?:CREATE|ALTER)\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([\w.]+)`?/gi;
142
+ let m;
143
+ while ((m = re.exec(dbMd))) {
144
+ tables.add(m[1].replace(/`/g, ''));
145
+ }
146
+ // 兜底:匹配形如 `t_xxx` 的表名行
147
+ const tableRe = /\b(t_[a-z_0-9]+)\b/g;
148
+ while ((m = tableRe.exec(dbMd))) tables.add(m[1]);
149
+ return [...tables];
150
+ }
151
+
152
+ /** 从 markdown 中提取所有 frontmatter 字段(简单正则,与 state-loader 兼容)。 */
153
+ export function readFrontmatter(content) {
154
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
155
+ if (!match) return {};
156
+ const fm = {};
157
+ for (const line of match[1].split('\n')) {
158
+ const m = line.match(/^(\w+):\s*(.*)$/);
159
+ if (m) fm[m[1]] = m[2].trim();
160
+ }
161
+ return fm;
162
+ }
@@ -0,0 +1,84 @@
1
+ // scripts/lib/cmd-arch.mjs — tf arch init/show:项目级架构基线打戳(v0.35.0,v0.14 §59.4/§63.1)
2
+ //
3
+ // arch_baseline 完全复刻 v0.13 §48.1 schema_version 防污染模式:
4
+ // 仅 `tf arch init` 打戳(项目级 .team-flow/arch-state.json),缺失 = 存量信号。
5
+ // 防污染:已打戳则拒绝重复 init(不覆盖);rebuild/doctor/set 一律不追加。
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { parseArgs } from 'node:util';
9
+
10
+ const ARCH_STATE_FILE = '.team-flow/arch-state.json';
11
+
12
+ export async function run(args) {
13
+ const { positionals, values } = parseArgs({
14
+ args,
15
+ options: {
16
+ 'project-root': { type: 'string' },
17
+ mode: { type: 'string', default: 'reconstruction' },
18
+ 'baseline-ref': { type: 'string', default: 'prd/vN/' },
19
+ },
20
+ allowPositionals: true,
21
+ });
22
+
23
+ const sub = positionals[0];
24
+ if (sub === 'init') return init(values);
25
+ if (sub === 'show') return show(values);
26
+ console.error('Usage: tf arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>] | tf arch show');
27
+ process.exit(2);
28
+ }
29
+
30
+ function findRoot(rootOpt) {
31
+ if (rootOpt) return path.resolve(rootOpt);
32
+ let dir = process.cwd();
33
+ while (dir !== path.dirname(dir)) {
34
+ if (fs.existsSync(path.join(dir, '.team-flow'))) return dir;
35
+ dir = path.dirname(dir);
36
+ }
37
+ return dir;
38
+ }
39
+
40
+ function readState(root) {
41
+ const target = path.join(root, ARCH_STATE_FILE);
42
+ if (!fs.existsSync(target)) return null;
43
+ try { return JSON.parse(fs.readFileSync(target, 'utf-8')); }
44
+ catch { return null; }
45
+ }
46
+
47
+ function init(values) {
48
+ const root = findRoot(values['project-root']);
49
+ const target = path.join(root, ARCH_STATE_FILE);
50
+ const mode = values.mode === 'design' ? 'design' : 'reconstruction';
51
+
52
+ const existing = readState(root);
53
+ if (existing?.arch_baseline) {
54
+ console.error(
55
+ `arch-state.json already established (arch_baseline=${existing.arch_baseline}, mode=${existing.mode})`
56
+ + ' — 防污染规则:不重复打戳。如需重建,先手工处理旧标记。'
57
+ );
58
+ process.exit(1);
59
+ }
60
+
61
+ const state = {
62
+ arch_baseline: 'v0',
63
+ established_at: new Date().toISOString(),
64
+ mode,
65
+ snapshot_root: 'iterations/',
66
+ baseline_prd_ref: values['baseline-ref'],
67
+ };
68
+
69
+ fs.mkdirSync(path.dirname(target), { recursive: true });
70
+ fs.writeFileSync(target, JSON.stringify(state, null, 2) + '\n');
71
+ console.log(`arch-state.json written: ${target}`);
72
+ console.log(JSON.stringify(state, null, 2));
73
+ console.log('项目架构基线已打戳,进入 design 模式。重建快照位于 docs/architecture/iterations/v0/。');
74
+ }
75
+
76
+ function show(values) {
77
+ const root = findRoot(values['project-root']);
78
+ const state = readState(root);
79
+ if (!state) {
80
+ console.log('arch_baseline: <unset>(存量/未建立,S3.5 reconstruction pending)');
81
+ return;
82
+ }
83
+ console.log(JSON.stringify(state, null, 2));
84
+ }
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ // scripts/lib/cmd-deisolate.mjs — worktree deisolation (v0.35.0)
3
+ //
4
+ // 检测 change 的 worktree 状态,输出合并建议或执行合并/清理。
5
+ // 与 ensure-branch.mjs(创建)对称,补全 worktree 生命周期。
6
+ //
7
+ // Usage:
8
+ // tf deisolate <change-dir> # dry-run: 输出状态报告
9
+ // tf deisolate <change-dir> --merge # 合并 worktree 分支回 master
10
+ // tf deisolate <change-dir> --merge --clean # 合并后删除 worktree + 分支
11
+ // tf deisolate <change-dir> --clean # 仅删除 worktree(不合并)
12
+ //
13
+ // 布局识别(对称 ensure-branch.mjs):
14
+ // Case A 多仓库工作区:<root>/.worktrees/<change>/<repo>/ 存在
15
+ // Case B 单仓库:<change-dir> 同级 ../<repo>-<change-name> 存在
16
+
17
+ import { execFileSync } from 'node:child_process';
18
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
19
+ import { basename, dirname, join, resolve } from 'node:path';
20
+ import { parseArgs } from 'node:util';
21
+
22
+ export async function run(args) {
23
+ const { positionals, values } = parseArgs({
24
+ args,
25
+ options: {
26
+ merge: { type: 'boolean', default: false },
27
+ clean: { type: 'boolean', default: false },
28
+ 'project-root': { type: 'string' },
29
+ json: { type: 'boolean', default: false },
30
+ help: { type: 'boolean', default: false },
31
+ },
32
+ allowPositionals: true,
33
+ });
34
+
35
+ if (values.help || positionals.length === 0) {
36
+ printHelp();
37
+ return;
38
+ }
39
+
40
+ const changeDir = resolve(positionals[0]);
41
+ const changeName = basename(changeDir);
42
+
43
+ // 检测布局
44
+ const workspaceRoot = detectWorkspaceRoot(changeDir);
45
+ const caseA = workspaceRoot ? detectCaseA(workspaceRoot, changeName) : null;
46
+ const caseB = detectCaseB(changeDir, changeName);
47
+
48
+ if (!caseA && !caseB) {
49
+ const msg = `No worktrees found for change '${changeName}'.`;
50
+ if (values.json) {
51
+ console.log(JSON.stringify({ found: false, message: msg }));
52
+ } else {
53
+ console.log(`✅ ${msg}`);
54
+ }
55
+ return;
56
+ }
57
+
58
+ const repos = caseA || caseB;
59
+ const results = [];
60
+
61
+ for (const repo of repos) {
62
+ const result = processRepo(repo, values);
63
+ results.push(result);
64
+ }
65
+
66
+ if (values.json) {
67
+ console.log(JSON.stringify({ found: true, repos: results }, null, 2));
68
+ } else {
69
+ printReport(results, values);
70
+ }
71
+ }
72
+
73
+ function detectWorkspaceRoot(absChangeDir) {
74
+ const parent = dirname(absChangeDir);
75
+ if (basename(parent) !== 'changes') return null;
76
+ return dirname(parent);
77
+ }
78
+
79
+ // Case A: <root>/.worktrees/<change>/<repo>/
80
+ function detectCaseA(root, changeName) {
81
+ const changeWorktreeDir = join(root, '.worktrees', changeName);
82
+ if (!existsSync(changeWorktreeDir)) return null;
83
+
84
+ let entries;
85
+ try {
86
+ entries = readdirSync(changeWorktreeDir, { withFileTypes: true });
87
+ } catch {
88
+ return null;
89
+ }
90
+
91
+ const repos = entries
92
+ .filter(e => e.isDirectory() && existsSync(join(changeWorktreeDir, e.name, '.git')))
93
+ .map(e => ({
94
+ name: e.name,
95
+ worktreePath: join(changeWorktreeDir, e.name),
96
+ repoPath: join(root, e.name),
97
+ layout: 'A',
98
+ }))
99
+ .sort((a, b) => a.name.localeCompare(b.name));
100
+
101
+ return repos.length > 0 ? repos : null;
102
+ }
103
+
104
+ // Case B: ../<repo>-<change-name>
105
+ function detectCaseB(changeDir, changeName) {
106
+ const parentDir = dirname(changeDir);
107
+ const repoName = basename(changeDir) || 'repo';
108
+ // 旧 ensure-branch 行为:worktree 在 ../<repo>-<name>
109
+ // 分支名经过 sanitize,这里用 changeName 作为近似匹配
110
+ const candidates = [
111
+ join(parentDir, `${repoName}-${changeName}`),
112
+ join(parentDir, `${repoName}-${basename(changeDir)}`),
113
+ ];
114
+
115
+ for (const wtPath of candidates) {
116
+ if (existsSync(join(wtPath, '.git'))) {
117
+ return [{
118
+ name: repoName,
119
+ worktreePath: wtPath,
120
+ repoPath: changeDir,
121
+ layout: 'B',
122
+ }];
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function processRepo(repo, values) {
129
+ const result = { name: repo.name, worktreePath: repo.worktreePath, layout: repo.layout };
130
+
131
+ // 获取 worktree 分支名
132
+ let branch = '';
133
+ try {
134
+ branch = (execFileSync('git', ['branch', '--show-current'], {
135
+ encoding: 'utf-8', cwd: repo.worktreePath, stdio: ['ignore', 'pipe', 'pipe'],
136
+ }) || '').trim();
137
+ } catch {
138
+ result.status = 'error';
139
+ result.message = 'Could not determine worktree branch';
140
+ return result;
141
+ }
142
+ result.branch = branch;
143
+
144
+ // 获取相对 master 的 diff
145
+ const masterBranch = detectMasterBranch(repo.repoPath);
146
+ try {
147
+ const ahead = (execFileSync('git', ['rev-list', '--count', `${masterBranch}..${branch}`], {
148
+ encoding: 'utf-8', cwd: repo.worktreePath, stdio: ['ignore', 'pipe', 'pipe'],
149
+ }) || '0').trim();
150
+ const behind = (execFileSync('git', ['rev-list', '--count', `${branch}..${masterBranch}`], {
151
+ encoding: 'utf-8', cwd: repo.worktreePath, stdio: ['ignore', 'pipe', 'pipe'],
152
+ }) || '0').trim();
153
+ result.ahead = parseInt(ahead, 10);
154
+ result.behind = parseInt(behind, 10);
155
+ } catch {
156
+ result.ahead = '?';
157
+ result.behind = '?';
158
+ }
159
+
160
+ // 检查未提交改动
161
+ try {
162
+ const status = execFileSync('git', ['status', '--porcelain'], {
163
+ encoding: 'utf-8', cwd: repo.worktreePath, stdio: ['ignore', 'pipe', 'pipe'],
164
+ });
165
+ result.dirty = status.trim().length > 0;
166
+ } catch {
167
+ result.dirty = '?';
168
+ }
169
+
170
+ // 操作
171
+ if (values.merge) {
172
+ result.merge = mergeBranch(repo, branch, masterBranch);
173
+ }
174
+ if (values.clean) {
175
+ result.clean = cleanWorktree(repo, branch);
176
+ }
177
+
178
+ result.status = 'ok';
179
+ return result;
180
+ }
181
+
182
+ function detectMasterBranch(repoPath) {
183
+ try {
184
+ const branch = (execFileSync('git', ['branch', '--show-current'], {
185
+ encoding: 'utf-8', cwd: repoPath, stdio: ['ignore', 'pipe', 'pipe'],
186
+ }) || '').trim();
187
+ if (branch) return branch;
188
+ } catch { /* ignore */ }
189
+ // fallback: try master, then main
190
+ for (const candidate of ['master', 'main']) {
191
+ try {
192
+ execFileSync('git', ['rev-parse', `refs/heads/${candidate}`], {
193
+ encoding: 'utf-8', cwd: repoPath, stdio: ['ignore', 'pipe', 'pipe'],
194
+ });
195
+ return candidate;
196
+ } catch { /* continue */ }
197
+ }
198
+ return 'master';
199
+ }
200
+
201
+ function mergeBranch(repo, branch, masterBranch) {
202
+ try {
203
+ const output = execFileSync('git', ['merge', branch, '--no-edit'], {
204
+ encoding: 'utf-8', cwd: repo.repoPath, stdio: ['ignore', 'pipe', 'pipe'],
205
+ });
206
+ return { success: true, message: output.trim() };
207
+ } catch (e) {
208
+ return { success: false, message: (e.stderr || e.stdout || e.message || '').toString().trim() };
209
+ }
210
+ }
211
+
212
+ function cleanWorktree(repo, branch) {
213
+ const result = { worktreeRemoved: false, branchDeleted: false, errors: [] };
214
+
215
+ // Remove worktree
216
+ try {
217
+ execFileSync('git', ['worktree', 'remove', repo.worktreePath, '--force'], {
218
+ encoding: 'utf-8', cwd: repo.repoPath, stdio: ['ignore', 'pipe', 'pipe'],
219
+ });
220
+ result.worktreeRemoved = true;
221
+ } catch (e) {
222
+ result.errors.push(`worktree remove: ${(e.stderr || e.message || '').toString().trim()}`);
223
+ // Fallback: rm -rf
224
+ try {
225
+ rmSync(repo.worktreePath, { recursive: true, force: true });
226
+ result.worktreeRemoved = true;
227
+ } catch (e2) {
228
+ result.errors.push(`rm fallback: ${e2.message}`);
229
+ }
230
+ }
231
+
232
+ // Delete branch (skip if merged)
233
+ try {
234
+ execFileSync('git', ['branch', '-d', branch], {
235
+ encoding: 'utf-8', cwd: repo.repoPath, stdio: ['ignore', 'pipe', 'pipe'],
236
+ });
237
+ result.branchDeleted = true;
238
+ } catch {
239
+ // Branch not merged — use -D only if --force equivalent
240
+ try {
241
+ execFileSync('git', ['branch', '-D', branch], {
242
+ encoding: 'utf-8', cwd: repo.repoPath, stdio: ['ignore', 'pipe', 'pipe'],
243
+ });
244
+ result.branchDeleted = true;
245
+ } catch (e) {
246
+ result.errors.push(`branch delete: ${(e.stderr || e.message || '').toString().trim()}`);
247
+ }
248
+ }
249
+
250
+ return result;
251
+ }
252
+
253
+ function printReport(results, values) {
254
+ console.log(`\n📦 Worktree Deisolation Report\n`);
255
+
256
+ for (const r of results) {
257
+ console.log(` Repo: ${r.name} (${r.layout === 'A' ? 'multi-repo' : 'single-repo'})`);
258
+ console.log(` Branch: ${r.branch || 'unknown'}`);
259
+ console.log(` Worktree: ${r.worktreePath}`);
260
+ if (r.ahead !== undefined) {
261
+ console.log(` Ahead of master: ${r.ahead} commit(s)`);
262
+ console.log(` Behind master: ${r.behind} commit(s)`);
263
+ }
264
+ console.log(` Uncommitted changes: ${r.dirty === true ? '⚠️ YES' : r.dirty === false ? 'no' : 'unknown'}`);
265
+
266
+ if (r.merge) {
267
+ console.log(` Merge: ${r.merge.success ? '✅ ' + r.merge.message : '❌ ' + r.merge.message}`);
268
+ }
269
+ if (r.clean) {
270
+ console.log(` Worktree removed: ${r.clean.worktreeRemoved ? '✅' : '❌'}`);
271
+ console.log(` Branch deleted: ${r.clean.branchDeleted ? '✅' : '❌'}`);
272
+ if (r.clean.errors.length > 0) {
273
+ console.log(` Clean errors: ${r.clean.errors.join('; ')}`);
274
+ }
275
+ }
276
+ console.log();
277
+ }
278
+
279
+ if (!values.merge && !values.clean) {
280
+ console.log(` To merge: tf deisolate <change-dir> --merge`);
281
+ console.log(` To clean: tf deisolate <change-dir> --merge --clean`);
282
+ }
283
+ }
284
+
285
+ function printHelp() {
286
+ console.log(`Usage:
287
+ tf deisolate <change-dir> # dry-run: show worktree status
288
+ tf deisolate <change-dir> --merge # merge worktree branch back to master
289
+ tf deisolate <change-dir> --merge --clean # merge + remove worktree + delete branch
290
+ tf deisolate <change-dir> --clean # remove worktree only (no merge)
291
+ tf deisolate <change-dir> --json # JSON output`);
292
+ }
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from 'node:util';
2
- import { createPlan, describeWaves, EXECUTION_MODES, readPlan, recordReview, validatePlan, writePlan } from './execution-plan.mjs';
2
+ import { createPlan, describeWaves, EXECUTION_MODES, readPlan, recordReview, refreshPlanHash, validatePlan, writePlan } from './execution-plan.mjs';
3
3
  import {
4
4
  createRecommendationReceipt,
5
5
  readCurrentRecommendationReceipt,
@@ -7,7 +7,7 @@ import {
7
7
  } from './execution-recommendation.mjs';
8
8
  import { readState, writeState } from './state-loader.mjs';
9
9
 
10
- const SUBCOMMANDS = ['recommend', 'plan', 'show', 'revise', 'review'];
10
+ const SUBCOMMANDS = ['recommend', 'plan', 'show', 'revise', 'review', 'refresh-hash'];
11
11
 
12
12
  export async function run(args) {
13
13
  const { positionals, values } = parseArgs({
@@ -52,6 +52,8 @@ export async function run(args) {
52
52
  return createAndPrintPlan(changeDir, values, true);
53
53
  case 'review':
54
54
  return recordAndPrintReview(changeDir, values);
55
+ case 'refresh-hash':
56
+ return refreshHash(changeDir, values.json);
55
57
  }
56
58
  }
57
59
 
@@ -203,6 +205,16 @@ function parseTestsStats(values) {
203
205
  return parsed;
204
206
  }
205
207
 
208
+ function refreshHash(changeDir, json) {
209
+ const result = refreshPlanHash(changeDir);
210
+ if (!result) {
211
+ print(json, { refreshed: false }, 'Artifacts hash is already up to date.');
212
+ return;
213
+ }
214
+ print(json, { refreshed: true, artifacts_hash: result.artifacts_hash, contract_hash: result.contract_hash },
215
+ `Refreshed artifacts hash: ${result.artifacts_hash?.slice(0, 12)}…`);
216
+ }
217
+
206
218
  function requireSafeReason(reason) {
207
219
  if (/[\p{Cc}\p{Zl}\p{Zp}]/u.test(reason)) {
208
220
  throw new Error('--reason must not contain control characters or line separators');
@@ -225,5 +237,6 @@ function printHelp() {
225
237
  tf execution plan <dir> --mode <mode> --confirm --reason <text> --wave <id>:<strategy>:<task,...>[:<depends-on,...>] [--acknowledge-recommendation]
226
238
  tf execution show <dir> [--json]
227
239
  tf execution revise <dir> --mode sdd --confirm --reason <text> --wave <id>:<strategy>:<task,...>[:<depends-on,...>] [--acknowledge-recommendation]
228
- tf execution review <dir> --wave <id> --base <sha> --head <sha> --report <path> --verdict pass|fail [--tests-total N --tests-passed N --tests-failed N]`);
240
+ tf execution review <dir> --wave <id> --base <sha> --head <sha> --report <path> --verdict pass|fail [--tests-total N --tests-passed N --tests-failed N]
241
+ tf execution refresh-hash <dir> [--json] # refresh artifacts/contract hash without bumping revision`);
229
242
  }
@@ -53,9 +53,17 @@ export function writePlan(changeDir, plan) {
53
53
  mkdirSync(paths.root, { recursive: true });
54
54
  const previousPlan = readPlan(changeDir);
55
55
  if (previousPlan && (previousPlan.revision !== plan.revision || previousPlan.hash !== plan.hash)) {
56
- // Review evidence is scoped to exactly one plan revision. Removing it on
57
- // revision prevents an old wave ID from satisfying a changed plan.
58
- rmSync(paths.reviews, { recursive: true, force: true });
56
+ // v0.35.0: Review receipts are RETAINED across revisions readCurrentReview
57
+ // validates plan_hash/plan_revision matching, so stale receipts are
58
+ // automatically invalidated without losing evidence (base/head/report SHA).
59
+ // This prevents legitimate artifact updates (e.g., design.md 横展结论) from
60
+ // causing cascading receipt loss. See workflow-feedback 20260805.
61
+ //
62
+ // Recommendation receipt is revision-scoped and must be cleaned:
63
+ const recommendationFile = paths.executionRecommendation;
64
+ if (recommendationFile && existsSync(recommendationFile)) {
65
+ rmSync(recommendationFile, { force: true });
66
+ }
59
67
  }
60
68
  atomicWrite(paths.executionPlan, `${JSON.stringify(plan, null, 2)}\n`);
61
69
  writeExecutionPlanSummary(changeDir, plan);
@@ -76,10 +84,16 @@ export function validatePlan(changeDir, plan) {
76
84
  failures.push('execution plan mode does not match state');
77
85
  }
78
86
  if (plan?.artifacts_hash !== computeArtifactsHash(changeDir)) {
79
- failures.push('execution plan is stale: artifacts hash mismatch');
87
+ const actual = computeArtifactsHash(changeDir);
88
+ const planPrefix = plan?.artifacts_hash?.slice(0, 12) ?? 'null';
89
+ const actualPrefix = actual?.slice(0, 12) ?? 'null';
90
+ failures.push(`execution plan is stale: artifacts hash mismatch (plan: ${planPrefix}…, current: ${actualPrefix}…)`);
80
91
  }
81
92
  if (plan?.contract_hash !== computeContractHash(changeDir)) {
82
- failures.push('execution plan is stale: contract hash mismatch');
93
+ const actual = computeContractHash(changeDir);
94
+ const planPrefix = plan?.contract_hash?.slice(0, 12) ?? 'null';
95
+ const actualPrefix = actual?.slice(0, 12) ?? 'null';
96
+ failures.push(`execution plan is stale: contract hash mismatch (plan: ${planPrefix}…, current: ${actualPrefix}…)`);
83
97
  }
84
98
  if (plan?.workflow !== state.workflow) {
85
99
  failures.push('execution plan workflow does not match state');
@@ -442,6 +456,24 @@ function hashPlan(plan) {
442
456
  return `sha256:${createHash('sha256').update(stableJson(content)).digest('hex')}`;
443
457
  }
444
458
 
459
+ /**
460
+ * v0.35.0: Refresh plan's artifacts_hash and contract_hash without bumping revision.
461
+ * Returns the updated plan (already persisted via writePlan), or null if no change needed.
462
+ */
463
+ export function refreshPlanHash(changeDir) {
464
+ const plan = readPlan(changeDir);
465
+ if (!plan) return null;
466
+ const newArtifactsHash = computeArtifactsHash(changeDir);
467
+ const newContractHash = computeContractHash(changeDir);
468
+ if (plan.artifacts_hash === newArtifactsHash && plan.contract_hash === newContractHash) {
469
+ return null; // no change needed
470
+ }
471
+ plan.artifacts_hash = newArtifactsHash;
472
+ plan.contract_hash = newContractHash;
473
+ plan.hash = hashPlan(plan);
474
+ return writePlan(changeDir, plan);
475
+ }
476
+
445
477
  function tryHashPlan(plan) {
446
478
  if (!isObject(plan)) return null;
447
479
  try {
@@ -534,7 +534,10 @@ async function main(argv, projectRoot) {
534
534
  console.log(`\n🎉 test-merge complete for ${changeName}`);
535
535
  }
536
536
 
537
- main().catch(err => {
538
- console.error('test-merge error:', err.message);
539
- process.exit(1);
540
- });
537
+ // 直接运行(v0.35.0 修复:main() 无参自调用崩溃 → 调用 run() 走正确参数解析)
538
+ if (import.meta.url === `file://${process.argv[1]}`) {
539
+ run(process.argv.slice(2)).catch(err => {
540
+ console.error('test-merge error:', err.message);
541
+ process.exit(1);
542
+ });
543
+ }