@xulthekl/team-flow 0.48.0 → 0.50.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 (75) 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 +2 -2
  9. package/CHANGELOG.md +76 -0
  10. package/GEMINI.md +1 -1
  11. package/INSTALL.md +3 -3
  12. package/README.md +3 -3
  13. package/agents/architecture-design.md +1 -1
  14. package/agents/code-reviewer.md +1 -1
  15. package/docs/README_en.md +2 -2
  16. package/docs/artifact-contract.md +2 -0
  17. package/docs/decision-points.md +23 -0
  18. package/docs/solutions/INDEX.md +0 -14
  19. package/docs/state-machine.md +1 -1
  20. package/docs/usage-guide.md +7 -4
  21. package/gemini-extension.json +1 -1
  22. package/hooks/session-start +2 -2
  23. package/llms.txt +1 -1
  24. package/package.json +2 -1
  25. package/plugin.json +1 -1
  26. package/scripts/guard/checks/compound-captured.mjs +6 -3
  27. package/scripts/guard/checks/tasks-complete.mjs +39 -3
  28. package/scripts/guard/checks/tasks-gate-exemptions.mjs +49 -0
  29. package/scripts/lib/arch-precheck.mjs +190 -0
  30. package/scripts/lib/cmd-arch.mjs +5 -1
  31. package/scripts/lib/cmd-doctor.mjs +9 -2
  32. package/scripts/lib/cmd-runtime.mjs +1 -0
  33. package/scripts/lib/cmd-state.mjs +2 -0
  34. package/scripts/lib/execution-plan.mjs +72 -25
  35. package/scripts/lib/execution-recommendation.mjs +4 -18
  36. package/scripts/lib/hash.mjs +41 -2
  37. package/scripts/lib/prototype-sync.mjs +2 -1
  38. package/scripts/lib/sdd-overlay.mjs +3 -2
  39. package/scripts/lib/solutions-capture.mjs +2 -1
  40. package/scripts/lib/solutions-index-gen.mjs +2 -1
  41. package/scripts/lib/solutions-inject.mjs +2 -1
  42. package/scripts/lib/solutions-promote.mjs +90 -12
  43. package/scripts/lib/state-loader.mjs +7 -0
  44. package/scripts/lib/test-record.mjs +67 -21
  45. package/scripts/lint/rules/behavior-consistency.mjs +3 -1
  46. package/scripts/team-flow.mjs +8 -1
  47. package/skills/architecture-design/SKILL.md +17 -3
  48. package/skills/build-executor/SKILL.md +10 -4
  49. package/skills/build-executor/references/execution-modes.md +1 -1
  50. package/skills/build-executor/task-reviewer-prompt.md +5 -4
  51. package/skills/ce-compound/references/promotion-rules.md +1 -1
  52. package/skills/ce-compound/references/schema.yaml +10 -1
  53. package/skills/code-reviewer/SKILL.md +1 -1
  54. package/skills/code-reviewer/code-reviewer-prompt.md +5 -4
  55. package/skills/contract-builder/SKILL.md +35 -4
  56. package/skills/contract-builder/references/glaf4-delegation.md +5 -3
  57. package/skills/release-archivist/SKILL.md +18 -10
  58. package/skills/release-archivist/references/closing-procedures.md +7 -2
  59. package/skills/workflow-start/SKILL.md +19 -7
  60. package/skills/workflow-start/references/routing-rules.md +3 -1
  61. package/templates/learnings.md +51 -0
  62. package/docs/solutions/cross-phase/2026-07-28-no-summary.md +0 -17
  63. package/docs/solutions/cross-phase/2026-07-29-no-summary.md +0 -17
  64. package/docs/solutions/cross-phase/2026-07-30-no-summary.md +0 -17
  65. package/docs/solutions/cross-phase/2026-07-31-no-summary.md +0 -17
  66. package/docs/solutions/cross-phase/2026-08-01-no-summary.md +0 -17
  67. package/docs/solutions/cross-phase/2026-08-03-no-summary.md +0 -17
  68. package/docs/solutions/cross-phase/2026-08-04-no-summary.md +0 -17
  69. package/docs/solutions/cross-phase/2026-08-05-no-summary.md +0 -17
  70. package/docs/solutions/cross-phase/2026-08-06-no-summary.md +0 -17
  71. package/docs/solutions/cross-phase/2026-08-07-no-summary.md +0 -17
  72. package/docs/solutions/cross-phase/2026-08-17-no-summary.md +0 -17
  73. package/docs/solutions/cross-phase/2026-08-18-no-summary.md +0 -17
  74. package/docs/solutions/cross-phase/2026-08-19-no-summary.md +0 -17
  75. package/docs/solutions/cross-phase/2026-08-21-no-summary.md +0 -17
@@ -0,0 +1,190 @@
1
+ // scripts/lib/arch-precheck.mjs — tf arch precheck:架构判断门的确定性证据工具(v0.22 §88.3.2)
2
+ //
3
+ // 定位:**证据工具,不是判断者**。本命令只输出"架构信号命中情况",
4
+ // 判断仍归 architecture-design 子代理(v0.9 §26 的独立性不变)。
5
+ // 退出码恒为 0 —— 不阻断流程,也不替代判断。
6
+ //
7
+ // 动机(workflow-feedback 20260909-112755):hotfix 对表现层文案改动跑完整架构门
8
+ // 消耗 41,864 tk,结论必然 skipped。让子代理在 signal=none 时只读 brief + 本命令输出
9
+ // (不读全量 docs/architecture/)可大幅降本,同时保留独立判断与回退能力。
10
+ //
11
+ // 设计约束(H1 实测校准):
12
+ // - 关键词须为**高置信度**短语——裸 `schema` / `api` 会误命中测试矩阵术语("schema 覆盖声明")
13
+ // - 否定表述须识别——"不动 filter.args" / "无聚合" 是**负向**证据,不能计为命中
14
+ // - 文件提取须去噪——`node_modules/` 引用非改动文件;同名文件按最完整路径去重
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ /** 架构关键词表(对应 architecture-design SKILL 五项检查 + 配置契约 key)。 */
19
+ const ARCH_KEYWORDS = [
20
+ // 1 聚合
21
+ '聚合', '聚合根', 'aggregate',
22
+ // 2 限界上下文
23
+ '限界上下文', 'bounded context',
24
+ // 3 读写模型
25
+ '读写模型', 'cqrs', '写模型', '读模型',
26
+ // 4 API(精确短语,避免裸 api 假阳性)
27
+ 'api 签名', 'api 端点', 'api 变更', 'api 新增', '新增 api', 'api 契约', 'endpoint', '端点',
28
+ // 5 DB schema(精确短语,避免裸 schema 误命中测试矩阵术语)
29
+ 'db schema', '数据库 schema', '表结构', 'ddl', '数据库表', '索引变更',
30
+ // 配置契约 key
31
+ 'filter.args',
32
+ ];
33
+
34
+ /**
35
+ * 否定上下文(行级):否定词可能出现在关键词**之后**同句(如"范围外…`filter.args` …一律不动")。
36
+ * 宽松匹配是刻意的——误判为 negated 只会让门走完整路径(安全),
37
+ * 误判为 positive 才会放行 fast path(危险)。
38
+ */
39
+ const LINE_NEGATION_RE = /(不动|不涉及|不变更|不新增|不修改|不改变|不调整|不包含|严禁|不得|禁止|零变更|无变更|无新增|无修改|保持不变|unchanged|no change|without)/i;
40
+ const NEGATION_ADJACENT_RE = /(不|无|非|未|零|no|not)\s*$/;
41
+
42
+ /**
43
+ * 变更语境:关键词前文出现变更动词才计为**正向**信号。
44
+ * 仅"提到"关键词(如"按 filter.args 断言写回")属中性,不构成架构变更信号。
45
+ */
46
+ const CHANGE_VERB_RE = /(新增|修改|变更|调整|增加|删除|引入|扩展|重构|改造|迁移|升级|add|new|modify|change|update|refactor|migrate)/i;
47
+
48
+ const PRESENTATION_EXTS = new Set(['.vue', '.jsx', '.tsx', '.less', '.css', '.scss', '.sass', '.html']);
49
+ const BACKEND_EXTS = new Set(['.java', '.go', '.py', '.rb', '.rs', '.kt', '.kts', '.cs']);
50
+ const CONFIG_EXTS = new Set(['.yaml', '.yml', '.json', '.sql', '.toml', '.ini', '.properties']);
51
+ const SCRIPT_EXTS = new Set(['.js', '.ts', '.mjs', '.cjs', '.mts', '.cts']);
52
+ const TEST_PATTERNS = [
53
+ /\.spec\.[cm]?[jt]sx?$/i,
54
+ /\.test\.[cm]?[jt]sx?$/i,
55
+ /Tests?\.java$/,
56
+ /_test\.go$/i,
57
+ /^test_.*\.py$/i,
58
+ /Tests?\.(kt|cs)$/,
59
+ ];
60
+
61
+ const CODE_EXT_PATTERN = [...PRESENTATION_EXTS, ...BACKEND_EXTS, ...CONFIG_EXTS, ...SCRIPT_EXTS]
62
+ .map(e => e.slice(1))
63
+ .sort((a, b) => b.length - a.length)
64
+ .join('|');
65
+
66
+ const FILE_RE = new RegExp(`(?:[\\w.-]+/)*[\\w.-]+\\.(?:${CODE_EXT_PATTERN})(?![\\w])`, 'gi');
67
+
68
+ function classifyFile(file) {
69
+ const ext = path.extname(file).toLowerCase();
70
+ const base = path.basename(file);
71
+ if (TEST_PATTERNS.some(re => re.test(base))) return 'test';
72
+ if (PRESENTATION_EXTS.has(ext)) return 'presentation';
73
+ if (BACKEND_EXTS.has(ext)) return 'backend';
74
+ if (CONFIG_EXTS.has(ext)) return 'config';
75
+ if (SCRIPT_EXTS.has(ext)) return 'script';
76
+ return 'other';
77
+ }
78
+
79
+ /** 文件提取:剔除 node_modules 引用,同名文件保留最完整路径。 */
80
+ function extractFiles(text) {
81
+ const byBase = new Map();
82
+ for (const m of text.matchAll(FILE_RE)) {
83
+ const f = m[0];
84
+ if (/(^|\/)node_modules\//.test(f)) continue;
85
+ const base = path.basename(f);
86
+ const prev = byBase.get(base);
87
+ if (!prev || f.length > prev.length) byBase.set(base, f);
88
+ }
89
+ return [...byBase.values()];
90
+ }
91
+
92
+ /** 关键词逐次出现的语境统计:negated(否定)/ positive(变更动词)/ neutral(仅提及)。 */
93
+ function findKeyword(text, keyword) {
94
+ const kw = keyword.toLowerCase();
95
+ const stat = { occurrences: 0, positive: 0, negated: 0, neutral: 0 };
96
+ for (const line of text.split('\n')) {
97
+ const lowerLine = line.toLowerCase();
98
+ let idx = lowerLine.indexOf(kw);
99
+ while (idx !== -1) {
100
+ stat.occurrences += 1;
101
+ const before = line.slice(Math.max(0, idx - 30), idx);
102
+ if (LINE_NEGATION_RE.test(line) || NEGATION_ADJACENT_RE.test(before)) stat.negated += 1;
103
+ else if (CHANGE_VERB_RE.test(before)) stat.positive += 1;
104
+ else stat.neutral += 1;
105
+ idx = lowerLine.indexOf(kw, idx + kw.length);
106
+ }
107
+ }
108
+ return stat;
109
+ }
110
+
111
+ /**
112
+ * 扫描 change 的规划制品,返回架构信号证据。
113
+ * @returns {{ signal: 'none'|'weak'|'strong', hits: object, evidence: string[], sources: string[] }}
114
+ */
115
+ export function collectArchSignals(changeDir) {
116
+ const sources = [];
117
+ for (const name of ['change-brief.md', 'execution-contract.md']) {
118
+ const p = path.join(changeDir, name);
119
+ if (fs.existsSync(p)) sources.push({ name, text: fs.readFileSync(p, 'utf-8') });
120
+ }
121
+
122
+ const text = sources.map(s => s.text).join('\n');
123
+
124
+ const keywords = [];
125
+ const negated = [];
126
+ const neutral = [];
127
+ for (const kw of ARCH_KEYWORDS) {
128
+ const stat = findKeyword(text, kw);
129
+ if (stat.occurrences === 0) continue;
130
+ if (stat.positive > 0) keywords.push(kw);
131
+ else if (stat.negated > 0) negated.push(kw);
132
+ else neutral.push(kw);
133
+ }
134
+
135
+ const files = { presentation: [], backend: [], config: [], script: [], test: [], other: [] };
136
+ for (const f of extractFiles(text)) files[classifyFile(f)].push(f);
137
+
138
+ const hasSql = files.config.some(f => f.toLowerCase().endsWith('.sql'));
139
+
140
+ let signal;
141
+ if (keywords.length > 0) signal = 'strong';
142
+ else if (files.backend.length > 0 || hasSql) signal = 'weak';
143
+ else if (files.presentation.length > 0) signal = 'none';
144
+ else signal = 'weak';
145
+
146
+ const evidence = [];
147
+ if (sources.length === 0) {
148
+ evidence.push('no change-brief.md / execution-contract.md found — signal defaults to weak');
149
+ }
150
+ evidence.push(keywords.length === 0
151
+ ? 'no architecture keyword in change context'
152
+ : `architecture keyword in change context: ${keywords.join(', ')}`);
153
+ if (negated.length > 0) evidence.push(`negated (not a signal): ${negated.join(', ')}`);
154
+ if (neutral.length > 0) evidence.push(`mentioned without change context (not a signal): ${neutral.join(', ')}`);
155
+ evidence.push(
156
+ `code files — presentation=${files.presentation.length}, backend=${files.backend.length}, `
157
+ + `config=${files.config.length}, script=${files.script.length}, test=${files.test.length}`,
158
+ );
159
+ if (signal === 'none') {
160
+ evidence.push('all evidence points to presentation-layer only — fast-path candidate (sub-agent must still confirm)');
161
+ }
162
+
163
+ return {
164
+ signal,
165
+ hits: { keywords, negatedKeywords: negated, neutralKeywords: neutral, files },
166
+ evidence,
167
+ sources: sources.map(s => s.name),
168
+ };
169
+ }
170
+
171
+ export async function run(positionals, values) {
172
+ const changeDirArg = positionals[0];
173
+ if (!changeDirArg) {
174
+ console.error('Usage: tf arch precheck <change-dir> [--json]');
175
+ process.exit(2);
176
+ }
177
+ const changeDir = path.resolve(changeDirArg);
178
+ const result = collectArchSignals(changeDir);
179
+
180
+ if (values.json) {
181
+ console.log(JSON.stringify(result, null, 2));
182
+ } else {
183
+ console.log(`arch precheck — signal: ${result.signal}`);
184
+ console.log(` sources: ${result.sources.join(', ') || '(none)'}`);
185
+ for (const line of result.evidence) console.log(` - ${line}`);
186
+ console.log(' note: evidence only — the architecture-design sub-agent still makes the decision.');
187
+ }
188
+ // 证据工具:恒以 0 退出,不阻断流程。
189
+ process.exit(0);
190
+ }
@@ -6,6 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { parseArgs } from 'node:util';
9
+ import * as archPrecheck from './arch-precheck.mjs';
9
10
 
10
11
  const ARCH_STATE_FILE = '.team-flow/arch-state.json';
11
12
 
@@ -16,6 +17,7 @@ export async function run(args) {
16
17
  'project-root': { type: 'string' },
17
18
  mode: { type: 'string', default: 'reconstruction' },
18
19
  'baseline-ref': { type: 'string', default: 'prd/vN/' },
20
+ json: { type: 'boolean', default: false },
19
21
  },
20
22
  allowPositionals: true,
21
23
  });
@@ -23,7 +25,9 @@ export async function run(args) {
23
25
  const sub = positionals[0];
24
26
  if (sub === 'init') return init(values);
25
27
  if (sub === 'show') return show(values);
26
- console.error('Usage: tf arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>] | tf arch show');
28
+ // v0.22 §88.3.2:架构门判据的确定性证据工具(证据 only,退出码恒 0)
29
+ if (sub === 'precheck') return archPrecheck.run(positionals.slice(1), values);
30
+ console.error('Usage: tf arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>] | tf arch show | tf arch precheck <change-dir> [--json]');
27
31
  process.exit(2);
28
32
  }
29
33
 
@@ -5,6 +5,8 @@ import { loadConfig } from './config-loader.mjs';
5
5
  import { PLATFORM_RUNTIME_INVENTORY } from './platform-runtime-inventory.mjs';
6
6
  // 非法 state 巡检所需的共享常量与读取器(来源:workflow-feedback 2026-08-01,#100004)。
7
7
  import { readState, VALID_STATES } from './state-loader.mjs';
8
+ // v0.22 §85:tasks_skipped 豁免判定复用 guard 侧唯一真相源,避免同一规则两处实现漂移。
9
+ import { hasExplicitTasksSkip } from '../guard/checks/tasks-gate-exemptions.mjs';
8
10
 
9
11
  const RUNTIME_SKILLS = new Set([
10
12
  'workflow-start', 'need-explorer', 'spec-writer', 'contract-builder',
@@ -258,8 +260,8 @@ function checkChangeStates(root) {
258
260
  return { pass: true, message: `${checked} change(s) have legal state values` };
259
261
  }
260
262
 
261
- // v0.13 §52 B4 测试门禁卫生巡检(C1-domain-policy 事件修复):
262
- // 1. test_matrix_skipped=true 必须附 test_matrix_skip_reason(可审计豁免);
263
+ // v0.13 §52 B4 测试门禁卫生巡检(C1-domain-policy 事件修复);v0.22 §85 扩展 tasks_skipped:
264
+ // 1. test_matrix_skipped / tasks_skipped = true 必须附对应 reason(可审计豁免);
263
265
  // 2. 状态引用的产物文件必须存在(arch_review_report / test_evidence_path)——
264
266
  // C1 现场曾出现 arch_review_report 指向不存在的 auto-review.md。
265
267
  // DP 时间戳顺序检查评估后未纳入:closing 后的 DP-7 时间戳晚于 last_transition 属合法,
@@ -283,6 +285,11 @@ function checkChangeTestGates(root) {
283
285
  && !(typeof state.test_matrix_skip_reason === 'string' && state.test_matrix_skip_reason.trim())) {
284
286
  issues.push(`${d}: test_matrix_skipped=true without test_matrix_skip_reason`);
285
287
  }
288
+ // v0.22 §85:tasks_skipped 沿用同一可审计豁免范式(跳过必须附理由),
289
+ // 判定复用 tasks-gate-exemptions 的共享谓词(与 guard 同一真相源)。
290
+ if (state.tasks_skipped === 'true' && !hasExplicitTasksSkip(state)) {
291
+ issues.push(`${d}: tasks_skipped=true without tasks_skip_reason`);
292
+ }
286
293
  for (const field of ['arch_review_report', 'test_evidence_path']) {
287
294
  const ref = state[field];
288
295
  if (typeof ref === 'string' && ref.trim() && !existsSync(join(changeDir, ref))) {
@@ -15,6 +15,7 @@ const ASSETS = new Set([
15
15
  'skills/build-executor/task-reviewer-prompt.md',
16
16
  'templates/design.md',
17
17
  'templates/execution-contract.md',
18
+ 'templates/learnings.md',
18
19
  'templates/proposal.md',
19
20
  'templates/spec.md',
20
21
  'templates/tasks.md',
@@ -38,6 +38,8 @@ const SETTABLE_FIELDS = [
38
38
  'compound_skipped',
39
39
  // Test matrix gate (v0.12 §45.4 + v0.13 §48.2)
40
40
  'test_matrix_skipped', 'test_matrix_skip_reason',
41
+ // Tasks gate (v0.22 §85:hotfix/tweak 显式跳过 tasks.md,须附理由)
42
+ 'tasks_skipped', 'tasks_skip_reason',
41
43
  ];
42
44
 
43
45
  export async function run(args) {
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
5
- import { computeArtifactsHash, computeContractHash } from './hash.mjs';
5
+ import { computeArtifactsHash, computeContractHash, hashObject, stableJson } from './hash.mjs';
6
6
  import { getOverlayPaths } from './sdd-overlay.mjs';
7
7
  import { readState } from './state-loader.mjs';
8
8
  import { detectWorkspaceRoot, findSubRepo } from './git-utils.mjs';
@@ -55,11 +55,11 @@ export function writePlan(changeDir, plan) {
55
55
  mkdirSync(paths.root, { recursive: true });
56
56
  const previousPlan = readPlan(changeDir);
57
57
  if (previousPlan && (previousPlan.revision !== plan.revision || previousPlan.hash !== plan.hash)) {
58
- // v0.35.0: Review receipts are RETAINED across revisions — readCurrentReview
59
- // validates plan_hash/plan_revision matching, so stale receipts are
60
- // automatically invalidated without losing evidence (base/head/report SHA).
61
- // This prevents legitimate artifact updates (e.g., design.md 横展结论) from
62
- // causing cascading receipt loss. See workflow-feedback 20260805.
58
+ // v0.35.0: Review receipts are RETAINED across revisions.
59
+ // v0.49.0 §83.3.2: 有效性判据改为 wave 定义指纹 —— receipt 只在该 wave 的定义
60
+ // 变化时失效,与 plan revision/artifacts_hash 无关(历史 receipt 因此可跨
61
+ // revise/refresh-hash 继续生效);无 wave_fingerprint 的存量 receipt 回退旧判据。
62
+ // See workflow-feedback 20260805 / 20260909.
63
63
  //
64
64
  // Recommendation receipt is revision-scoped and must be cleaned:
65
65
  const recommendationFile = paths.executionRecommendation;
@@ -171,6 +171,8 @@ export function recordReview(changeDir, waveId, receipt, repoPath) {
171
171
  // Persist repo path when SHA was resolved in a sub-repository, so
172
172
  // readCurrentReview can re-validate without re-scanning.
173
173
  ...(subRepoPath ? { repo: relative(changeDir, subRepoPath) } : {}),
174
+ // v0.49.0 §83.3.2:主判据 —— wave 定义指纹;plan_hash/plan_revision 降级为审计信息
175
+ wave_fingerprint: computeWaveFingerprint(wave),
174
176
  plan_hash: plan.hash,
175
177
  plan_revision: plan.revision,
176
178
  recorded_at: new Date().toISOString(),
@@ -182,16 +184,25 @@ export function recordReview(changeDir, waveId, receipt, repoPath) {
182
184
  }
183
185
 
184
186
  /**
185
- * Returns the current plan's receipt for one wave. Receipts from a previous
186
- * revision/hash are never evidence for the current plan.
187
+ * Returns the current plan's receipt for one wave.
188
+ *
189
+ * v0.49.0 §83.3.2:有效性判据为 **wave 定义指纹**(id/strategy/tasks/depends_on)——
190
+ * receipt 只在该 wave 的定义变化时失效,与 plan 的 revision/artifacts_hash 无关。
191
+ * 无 wave_fingerprint 的存量 receipt(v0.48.0 及以前)回退旧的 plan 全内容判据。
187
192
  */
188
193
  export function readCurrentReview(changeDir, waveId, plan = readPlan(changeDir)) {
189
194
  if (!plan) return null;
195
+ const wave = Array.isArray(plan.waves) ? plan.waves.find(candidate => candidate?.id === waveId) : null;
196
+ if (!wave) return null;
190
197
  const filePath = join(getOverlayPaths(changeDir).reviews, `${safeFileName(waveId)}.json`);
191
198
  if (!existsSync(filePath)) return null;
192
199
  try {
193
200
  const receipt = JSON.parse(readFileSync(filePath, 'utf8'));
194
- if (receipt?.plan_hash !== plan.hash || receipt?.plan_revision !== plan.revision) return null;
201
+ if (receipt?.wave_fingerprint !== undefined) {
202
+ if (receipt.wave_fingerprint !== computeWaveFingerprint(wave)) return null;
203
+ } else if (receipt?.plan_hash !== plan.hash || receipt?.plan_revision !== plan.revision) {
204
+ return null;
205
+ }
195
206
  // When the receipt records a repo path (sub-repo scenario), use it for
196
207
  // SHA re-validation. Otherwise fall back to the default main-repo path.
197
208
  const repoPath = receipt?.repo ? resolve(changeDir, receipt.repo) : undefined;
@@ -218,6 +229,8 @@ export function describeWaves(changeDir, plan = readPlan(changeDir)) {
218
229
  const receipt = readCurrentReview(changeDir, wave.id, plan);
219
230
  const blockers = blockedDependencies(changeDir, plan, wave);
220
231
  const retryable = receipt?.status === 'fail';
232
+ // v0.49.0 §83.3.2:receipt 文件存在但未被采纳时给出审计线索(来源:workflow-feedback 20260909)
233
+ const staleReceipt = receipt === null ? readStaleReview(changeDir, wave.id) : null;
221
234
  return {
222
235
  id: wave.id,
223
236
  strategy: wave.strategy,
@@ -226,11 +239,32 @@ export function describeWaves(changeDir, plan = readPlan(changeDir)) {
226
239
  eligible: (receipt === null || retryable) && blockers.length === 0,
227
240
  retryable,
228
241
  receipt,
242
+ ...(staleReceipt ? { stale_receipt: staleReceipt } : {}),
229
243
  blockers,
230
244
  };
231
245
  });
232
246
  }
233
247
 
248
+ /**
249
+ * v0.49.0 §83.3.2:诊断信息 —— receipt 文件存在但因判据失配而未被采纳时返回审计线索。
250
+ * 不参与任何门禁判定(门禁只看 readCurrentReview)。
251
+ */
252
+ function readStaleReview(changeDir, waveId) {
253
+ const filePath = join(getOverlayPaths(changeDir).reviews, `${safeFileName(waveId)}.json`);
254
+ if (!existsSync(filePath)) return null;
255
+ try {
256
+ const receipt = JSON.parse(readFileSync(filePath, 'utf8'));
257
+ return {
258
+ status: receipt?.status ?? null,
259
+ plan_revision: receipt?.plan_revision ?? null,
260
+ recorded_at: receipt?.recorded_at ?? null,
261
+ reason: 'the wave definition changed after this receipt was recorded',
262
+ };
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
234
268
  function validateReviewReportEvidence(changeDir, report) {
235
269
  requireText(report, 'receipt.report');
236
270
  if (/[\p{Cc}\p{Zl}\p{Zp}]/u.test(report)) {
@@ -516,13 +550,35 @@ function hasDependencyCycle(waves) {
516
550
  }
517
551
 
518
552
  function hashPlan(plan) {
519
- const { hash, ...content } = plan;
520
- return `sha256:${createHash('sha256').update(stableJson(content)).digest('hex')}`;
553
+ // v0.49.0 §83.3.3:stableJson/hashObject 抽到 hash.mjs 共享(原为本文件与
554
+ // execution-recommendation.mjs 各一份副本)
555
+ return hashObject(plan);
556
+ }
557
+
558
+ /**
559
+ * v0.49.0 §83.3.2(来源:workflow-feedback 2026-09-09):wave 定义指纹 —— review receipt 的有效性判据。
560
+ *
561
+ * receipt 证明的是"该 wave 的实现在 base..head 间完成并已审查",其语义依赖仅为该 wave 的定义
562
+ * (id/strategy/tasks/depends_on)+ git 事实 + 审查报告,**不含** plan 的其余内容
563
+ * (rationale / artifacts_hash / contract_hash / revision / recommendation_receipt)。
564
+ * 绑定 plan 全内容会使任何与 wave 无关的变更(refresh-hash 刷新制品 hash、revise 升号)
565
+ * 误杀全部历史 receipt,且无补录路径。
566
+ */
567
+ function computeWaveFingerprint(wave) {
568
+ return `sha256:${createHash('sha256').update(stableJson({
569
+ id: wave?.id,
570
+ strategy: wave?.strategy,
571
+ tasks: wave?.tasks,
572
+ depends_on: wave?.depends_on,
573
+ })).digest('hex')}`;
521
574
  }
522
575
 
523
576
  /**
524
577
  * v0.35.0: Refresh plan's artifacts_hash and contract_hash without bumping revision.
525
578
  * Returns the updated plan (already persisted via writePlan), or null if no change needed.
579
+ *
580
+ * v0.49.0 §83.3.3:同步刷新内嵌 recommendation_receipt 的两个 hash(两者语义恒等),
581
+ * 否则 refresh-hash 之后 validatePlan 立即 FAIL(来源:workflow-feedback 20260909)。
526
582
  */
527
583
  export function refreshPlanHash(changeDir) {
528
584
  const plan = readPlan(changeDir);
@@ -534,6 +590,11 @@ export function refreshPlanHash(changeDir) {
534
590
  }
535
591
  plan.artifacts_hash = newArtifactsHash;
536
592
  plan.contract_hash = newContractHash;
593
+ if (plan.recommendation_receipt) {
594
+ plan.recommendation_receipt.artifacts_hash = newArtifactsHash;
595
+ plan.recommendation_receipt.contract_hash = newContractHash;
596
+ plan.recommendation_receipt.hash = hashObject(plan.recommendation_receipt);
597
+ }
537
598
  plan.hash = hashPlan(plan);
538
599
  return writePlan(changeDir, plan);
539
600
  }
@@ -547,20 +608,6 @@ function tryHashPlan(plan) {
547
608
  }
548
609
  }
549
610
 
550
- function stableJson(value, seen = new WeakSet()) {
551
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
552
- if (seen.has(value)) throw new Error('circular plan data');
553
- seen.add(value);
554
- if (Array.isArray(value)) {
555
- const result = `[${value.map(item => stableJson(item, seen)).join(',')}]`;
556
- seen.delete(value);
557
- return result;
558
- }
559
- const result = `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`;
560
- seen.delete(value);
561
- return result;
562
- }
563
-
564
611
  function writeExecutionPlanSummary(changeDir, plan) {
565
612
  const statePath = join(changeDir, '.team-flow.yaml');
566
613
  const state = readState(changeDir);
@@ -1,10 +1,10 @@
1
1
  // Evidence-based execution-mode recommendation for DP-4.
2
2
 
3
- import { createHash, randomUUID } from 'node:crypto';
3
+ import { randomUUID } from 'node:crypto';
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { loadConfig } from './config-loader.mjs';
7
- import { computeArtifactsHash, computeContractHash } from './hash.mjs';
7
+ import { computeArtifactsHash, computeContractHash, hashObject, stableJson } from './hash.mjs';
8
8
  import { getOverlayPaths } from './sdd-overlay.mjs';
9
9
  import { readState } from './state-loader.mjs';
10
10
  import { EXECUTION_MODES } from './execution-plan.mjs';
@@ -205,22 +205,8 @@ function normalizeWaves(waves) {
205
205
  }
206
206
 
207
207
  function hashReceipt(receipt) {
208
- const { hash, ...content } = receipt || {};
209
- return `sha256:${createHash('sha256').update(stableJson(content)).digest('hex')}`;
210
- }
211
-
212
- function stableJson(value, seen = new WeakSet()) {
213
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
214
- if (seen.has(value)) throw new Error('circular recommendation data');
215
- seen.add(value);
216
- if (Array.isArray(value)) {
217
- const result = `[${value.map(item => stableJson(item, seen)).join(',')}]`;
218
- seen.delete(value);
219
- return result;
220
- }
221
- const result = `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`;
222
- seen.delete(value);
223
- return result;
208
+ // v0.49.0 §83.3.3:stableJson/hashObject 抽到 hash.mjs 共享
209
+ return hashObject(receipt);
224
210
  }
225
211
 
226
212
  function atomicWrite(targetPath, content) {
@@ -4,6 +4,45 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { findCanonicalSpecFiles } from './spec-paths.mjs';
6
6
 
7
+ /**
8
+ * Normalize tasks.md checkbox state: `- [x]` / `- [X]` → `- [ ]`.
9
+ *
10
+ * v0.49.0 §83.3.1(来源:workflow-feedback 2026-09-09):tasks.md 同时是规划制品
11
+ * (artifacts_hash 的冻结对象)与执行台账(tasks-complete guard 要求执行期回写勾选)。
12
+ * 勾选状态属执行进度、不属规划内容 —— 归一化后入 hash,使"执行期勾选"不再使 plan
13
+ * 过期(否则勾选后 recordReview 因 plan stale 抛错,形成 closing 死锁)。
14
+ * 任务文本、数量与结构的变更仍会改变 hash。
15
+ */
16
+ export function normalizeCheckboxes(content) {
17
+ return content.replace(/^([ \t]*- )\[[xX]\]/gm, '$1[ ]');
18
+ }
19
+
20
+ /**
21
+ * 稳定序列化:对象键排序后递归序列化,保证同一内容产生同一字符串。
22
+ *
23
+ * v0.49.0 §83.3.3:从 execution-plan.mjs / execution-recommendation.mjs 抽取共享
24
+ * (原为两份实现),供 plan hash 与 recommendation receipt hash 共用。
25
+ */
26
+ export function stableJson(value, seen = new WeakSet()) {
27
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
28
+ if (seen.has(value)) throw new Error('circular data');
29
+ seen.add(value);
30
+ if (Array.isArray(value)) {
31
+ const result = `[${value.map(item => stableJson(item, seen)).join(',')}]`;
32
+ seen.delete(value);
33
+ return result;
34
+ }
35
+ const result = `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`;
36
+ seen.delete(value);
37
+ return result;
38
+ }
39
+
40
+ /** 通用对象 hash:剔除顶层 `hash` 字段后取 sha256。 */
41
+ export function hashObject(value) {
42
+ const { hash, ...content } = value ?? {};
43
+ return `sha256:${crypto.createHash('sha256').update(stableJson(content)).digest('hex')}`;
44
+ }
45
+
7
46
  // Compute a joint SHA256 hash of all 4 planning artifacts.
8
47
  // Input: proposal.md + specs/*/spec.md (sorted) + design.md + tasks.md
9
48
  export function computeArtifactsHash(changeDir) {
@@ -30,10 +69,10 @@ export function computeArtifactsHash(changeDir) {
30
69
  hasContent = true;
31
70
  }
32
71
 
33
- // tasks.md
72
+ // tasks.md(v0.49.0 §83.3.1:勾选状态归一化后入 hash —— 执行进度不属规划内容)
34
73
  const tasks = path.join(changeDir, 'tasks.md');
35
74
  if (fs.existsSync(tasks)) {
36
- hash.update(fs.readFileSync(tasks, 'utf-8'));
75
+ hash.update(normalizeCheckboxes(fs.readFileSync(tasks, 'utf-8')));
37
76
  hasContent = true;
38
77
  }
39
78
 
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { readFileSync, writeFileSync, existsSync, cpSync, mkdirSync } from 'node:fs';
18
18
  import { join, basename, relative, sep, resolve } from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
19
20
 
20
21
  /**
21
22
  * 解析 CLI 参数数组为结构化对象
@@ -216,7 +217,7 @@ export function run(args = {}) {
216
217
  }
217
218
 
218
219
  // 支持直接执行:node prototype-sync.mjs <change-dir> --source <path>
219
- if (process.argv[1]?.includes('prototype-sync')) {
220
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
220
221
  const args = { _: [] };
221
222
  for (let i = 2; i < process.argv.length; i++) {
222
223
  if (process.argv[i].startsWith('--')) {
@@ -3,7 +3,7 @@ import {
3
3
  existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync,
4
4
  } from 'node:fs';
5
5
  import { dirname, join } from 'node:path';
6
- import { computeArtifactsHash } from './hash.mjs';
6
+ import { computeArtifactsHash, normalizeCheckboxes } from './hash.mjs';
7
7
  import { readState } from './state-loader.mjs';
8
8
 
9
9
  export const HANDOFF_TYPES = new Set(['prototype', 'research', 'experiment']);
@@ -30,7 +30,8 @@ export function computeTaskHash(changeDir, taskId) {
30
30
  const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
31
  const match = tasks.match(new RegExp(`^- \\[([ xX])\\] ${escaped}\\s+.+$`, 'm'));
32
32
  if (!match) throw new Error(`Task '${taskId}' was not found in tasks.md`);
33
- return `sha256:${createHash('sha256').update(match[0]).digest('hex')}`;
33
+ // v0.49.0 §83.3.1:勾选状态归一化 —— 勾选是执行进度,不应使 checkpoint 变 stale
34
+ return `sha256:${createHash('sha256').update(normalizeCheckboxes(match[0])).digest('hex')}`;
34
35
  }
35
36
 
36
37
  export function saveCheckpoint(changeDir, input) {
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
16
17
 
17
18
  const PHASES = ['prd', 'plan', 'architecture', 'prototype', 'spec', 'build', 'review', 'cross-phase'];
18
19
  const MAX_INDEX_LINES = 150;
@@ -89,7 +90,7 @@ ${summary}
89
90
  return { file: `${phase}/${fileName}`, phase, domain, type, severity };
90
91
  }
91
92
 
92
- if (process.argv[1]?.includes('solutions-capture')) {
93
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
93
94
  const args = {};
94
95
  for (let i = 2; i < process.argv.length; i++) {
95
96
  if (process.argv[i].startsWith('--')) {
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
+ import { pathToFileURL } from 'node:url';
18
19
 
19
20
  const PHASES = ['prd', 'plan', 'prototype', 'spec', 'build', 'review', 'cross-phase'];
20
21
  const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
@@ -104,7 +105,7 @@ export function run(args = {}) {
104
105
  }
105
106
 
106
107
  // CLI 直接执行
107
- if (process.argv[1]?.includes('solutions-index-gen')) {
108
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
108
109
  const args = {};
109
110
  const dirIdx = process.argv.indexOf('--dir');
110
111
  if (dirIdx > -1 && process.argv[dirIdx + 1]) args.dir = process.argv[dirIdx + 1];
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { readFileSync, existsSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
17
18
 
18
19
  const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
19
20
 
@@ -66,7 +67,7 @@ export function run(args = {}) {
66
67
  return { entries: top5 };
67
68
  }
68
69
 
69
- if (process.argv[1]?.includes('solutions-inject')) {
70
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
70
71
  const args = {};
71
72
  for (let i = 2; i < process.argv.length; i += 2) {
72
73
  const key = process.argv[i]?.replace('--', '');