@xulthekl/team-flow 0.49.0 → 0.51.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 (61) 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 +67 -0
  9. package/GEMINI.md +1 -1
  10. package/INSTALL.md +2 -2
  11. package/README.md +1 -1
  12. package/agents/architecture-design.md +1 -1
  13. package/agents/code-reviewer.md +1 -1
  14. package/docs/README_en.md +2 -2
  15. package/docs/artifact-contract.md +2 -0
  16. package/docs/decision-points.md +23 -0
  17. package/docs/state-machine.md +1 -1
  18. package/docs/usage-guide.md +7 -4
  19. package/gemini-extension.json +1 -1
  20. package/hooks/session-start +2 -2
  21. package/llms.txt +1 -1
  22. package/package.json +2 -1
  23. package/plugin.json +1 -1
  24. package/scripts/ensure-branch.mjs +58 -32
  25. package/scripts/guard/checks/tasks-complete.mjs +39 -3
  26. package/scripts/guard/checks/tasks-gate-exemptions.mjs +49 -0
  27. package/scripts/lib/arch-merge.mjs +32 -9
  28. package/scripts/lib/arch-precheck.mjs +190 -0
  29. package/scripts/lib/cmd-arch.mjs +5 -1
  30. package/scripts/lib/cmd-deisolate.mjs +15 -16
  31. package/scripts/lib/cmd-doctor.mjs +9 -2
  32. package/scripts/lib/cmd-publish.mjs +4 -7
  33. package/scripts/lib/cmd-state.mjs +2 -0
  34. package/scripts/lib/conventions-generator.mjs +9 -7
  35. package/scripts/lib/execution-plan.mjs +18 -3
  36. package/scripts/lib/git-utils.mjs +172 -14
  37. package/scripts/lib/glaf4-delegation.mjs +5 -2
  38. package/scripts/lib/severity.mjs +71 -0
  39. package/scripts/lib/solutions-capture.mjs +14 -0
  40. package/scripts/lib/solutions-index-gen.mjs +2 -2
  41. package/scripts/lib/solutions-inject.mjs +2 -3
  42. package/scripts/lib/solutions-promote.mjs +16 -8
  43. package/scripts/lib/state-loader.mjs +7 -0
  44. package/scripts/lint/rules/behavior-consistency.mjs +3 -1
  45. package/scripts/team-flow.mjs +8 -1
  46. package/skills/architecture-design/SKILL.md +17 -3
  47. package/skills/build-executor/SKILL.md +2 -2
  48. package/skills/build-executor/references/execution-modes.md +1 -1
  49. package/skills/build-executor/task-reviewer-prompt.md +5 -4
  50. package/skills/ce-compound/references/promotion-rules.md +9 -2
  51. package/skills/ce-compound/references/three-tier-index.md +1 -1
  52. package/skills/ce-compound/references/write-flow.md +1 -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/release-archivist/SKILL.md +14 -3
  57. package/skills/release-archivist/references/closing-procedures.md +1 -1
  58. package/skills/release-archivist/references/worktree-merge.md +1 -1
  59. package/skills/workflow-start/SKILL.md +18 -7
  60. package/skills/workflow-start/references/routing-rules.md +3 -1
  61. package/templates/learnings.md +2 -2
@@ -0,0 +1,49 @@
1
+ // scripts/guard/checks/tasks-gate-exemptions.mjs — v0.22 §85 tasks-complete 门禁豁免
2
+ //
3
+ // 背景(workflow-feedback 20260909-103500-guard):
4
+ // hotfix/tweak 跳过 spec-writer(workflow-start SKILL.md:172/173),tasks.md 的任务文本
5
+ // 无原生产出者;而 guard 的 executing:closing 仍考核 tasks-complete(guard.mjs:75/82)。
6
+ // 修复前该矛盾导致 closing 死锁,只能由编排层补录 tasks.md 绕过。
7
+ //
8
+ // 本模块与 test-gate-exemptions.mjs 同构:显式跳过优于静默豁免(v0.13 §48 原则),
9
+ // 跳过必须附理由才放行;存量 change(无 schema_version)沿用 legacy 豁免。
10
+ import { isLegacyChange } from './test-gate-exemptions.mjs';
11
+
12
+ export { isLegacyChange };
13
+
14
+ /** v0.22 §85:显式跳过 tasks.md 且理由非空(可审计豁免)。 */
15
+ export function hasExplicitTasksSkip(state) {
16
+ return state?.tasks_skipped === 'true'
17
+ && typeof state?.tasks_skip_reason === 'string'
18
+ && state.tasks_skip_reason.trim().length > 0;
19
+ }
20
+
21
+ /** skip 已置但理由缺失 → 门禁拒绝并引导补理由。 */
22
+ export function tasksSkipMissingReason(state) {
23
+ return state?.tasks_skipped === 'true' && !hasExplicitTasksSkip(state);
24
+ }
25
+
26
+ /**
27
+ * v0.22 §85.4(LT 2026-09-09 决策):`tasks_skipped` 仅对 hotfix/tweak 合法。
28
+ * full 的 tasks.md 是 spec-writer 核心产物,必然产出——缺失即流程异常,应 FAIL 暴露而非豁免掩盖。
29
+ * `auto`/缺失归一化为 full(与 cmd-state.mjs:163 的 auto→full 归一化同口径,安全侧默认)。
30
+ * 无存量风险:tasks_skipped 是 v0.50.0 新字段。
31
+ */
32
+ export function isFullWorkflow(state) {
33
+ const workflow = state?.workflow;
34
+ return workflow == null || workflow === '' || workflow === 'auto' || workflow === 'full';
35
+ }
36
+
37
+ /** full/auto 置了 skip → 拒绝,并给出两条可操作出路(不是死锁)。 */
38
+ export function fullWorkflowSkipViolation(state) {
39
+ return state?.tasks_skipped === 'true' && isFullWorkflow(state);
40
+ }
41
+
42
+ export const TASKS_SKIP_REASON_HINT =
43
+ 'tasks_skipped=true requires tasks_skip_reason — record it: '
44
+ + "tf state set <dir> tasks_skip_reason '<why this change needs no tasks.md>'";
45
+
46
+ export const TASKS_SKIP_FULL_FORBIDDEN_HINT =
47
+ 'tasks_skipped is only valid for hotfix/tweak — full workflow must produce tasks.md. '
48
+ + 'Either produce tasks.md, or, if this change is truly a hotfix/tweak, '
49
+ + 'declare the mode first: tf state set <dir> workflow <hotfix|tweak>';
@@ -18,14 +18,14 @@
18
18
  import {
19
19
  readFileSync, writeFileSync as fsWriteFileSync, existsSync, mkdirSync, readdirSync, rmdirSync, cpSync,
20
20
  } from 'node:fs';
21
- import { join, basename, dirname, resolve } from 'node:path';
21
+ import { join, basename, dirname, relative, resolve } from 'node:path';
22
22
  import { execFileSync } from 'node:child_process';
23
23
  import { pathToFileURL } from 'node:url';
24
24
  import {
25
25
  parseTableAfter, extractEndpoints, extractAggregates,
26
26
  extractTablesFromSql, extractTablesFromDatabaseMd, readFrontmatter,
27
27
  } from './arch-parse.mjs';
28
- import { detectWorkspaceRoot, getGitRoot } from './git-utils.mjs';
28
+ import { detectWorkspaceRoot, getGitRoot, parsePorcelainPaths } from './git-utils.mjs';
29
29
 
30
30
  const MARKER_BEGIN = '<!-- arch:current-state:begin -->';
31
31
  const MARKER_END = '<!-- arch:current-state:end -->';
@@ -420,8 +420,29 @@ function gitCommit(projectRoot, globalArchDir, touchedFiles, changeName, dryRun)
420
420
  for (const f of touchedFiles) {
421
421
  execFileSync('git', ['add', f], { cwd: projectRoot, stdio: 'pipe' });
422
422
  }
423
- execFileSync('git', ['commit', '-m', commitMsg], { cwd: projectRoot, stdio: 'pipe' });
424
- return { committed: true, message: commitMsg };
423
+ // v0.23 §93.3.2:commit 必须带 pathspec——裸 `git commit` 提交索引中**全部已暂存内容**,
424
+ // 索引里若有他人/前序工具(如 tf publish --arch)留下的 staged 文件会被一并卷走。
425
+ execFileSync('git', ['commit', '-m', commitMsg, '--', ...touchedFiles], { cwd: projectRoot, stdio: 'pipe' });
426
+
427
+ // v0.23 §93.3.3:回读实际提交清单与白名单比对(第二道防线,不阻断——提交已完成)
428
+ // 前提:projectRoot 即 git 仓根(`git show --name-only` 输出仓根相对路径,此处按 projectRoot
429
+ // 还原为绝对路径)。projectRoot 取自 detectWorkspaceRoot(changeDir) || getGitRoot(changeDir),
430
+ // 实践中成立;显式传 --project-root 指向子目录时该前提不成立,会误报为白名单外(已知边界)。
431
+ const committed = String(execFileSync(
432
+ 'git', ['show', '--name-only', '--format=', 'HEAD'],
433
+ { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
434
+ )).split('\n').filter(Boolean).map(p => resolve(projectRoot, p));
435
+ const whitelist = new Set(touchedFiles.map(p => resolve(projectRoot, p)));
436
+ const unexpected = committed.filter(p => !whitelist.has(p));
437
+ if (unexpected.length > 0) {
438
+ console.warn(` [WARN] 本次 commit 含白名单外的文件(${unexpected.length} 个):${unexpected.map(p => relative(projectRoot, p)).join(', ')}`);
439
+ }
440
+ return {
441
+ committed: true,
442
+ message: commitMsg,
443
+ files: committed.map(p => relative(projectRoot, p)),
444
+ unexpected,
445
+ };
425
446
  } catch (e) {
426
447
  return { committed: false, error: e.message };
427
448
  }
@@ -433,10 +454,11 @@ function detectUntouchedDirtyFiles(projectRoot, globalArchDir, touchedFiles) {
433
454
  const status = execFileSync('git', ['status', '--porcelain', '--', globalArchDir], {
434
455
  cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
435
456
  });
436
- const dirty = status.split('\n').filter(Boolean)
437
- .map(line => line.replace(/^\S+\s+/, '').trim())
438
- .filter(p => p && !touchedFiles.includes(resolve(p)) && !touchedFiles.includes(p));
439
- return dirty;
457
+ // v0.23 §93.3.1:改用共享解析器。原 `^\S+\s+` 无法剥离 ` M path`(行首空格 = 未暂存修改,
458
+ // 最常见形态)→ 路径残留状态码 → 本次 touch 的文件被误报为「非本次 touch 的脏文件」。
459
+ // 路径口径统一为「相对 projectRoot 的绝对路径」后再与白名单比对,不依赖 process.cwd()
460
+ const touched = new Set(touchedFiles.map(p => resolve(projectRoot, p)));
461
+ return parsePorcelainPaths(status).filter(p => p && !touched.has(resolve(projectRoot, p)));
440
462
  } catch {
441
463
  return [];
442
464
  }
@@ -533,7 +555,8 @@ export function run(args = {}) {
533
555
  ].filter(p => existsSync(p));
534
556
  const dirty = detectUntouchedDirtyFiles(projectRoot, globalArchDir, touchedFiles);
535
557
  if (dirty.length > 0) {
536
- console.warn(` [WARN] docs/architecture/ 存在非本次 touch 的脏文件(${dirty.length} 个):${dirty.slice(0, 5).join(', ')} 白名单 git add 不卷走它们,但请确认是否他人未提交工作`);
558
+ console.warn(` [WARN] docs/architecture/ 检测到 ${dirty.length} 个非本次 touch 的改动:${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ' …' : ''}`);
559
+ console.warn(` 本次提交范围以白名单为准(${touchedFiles.length} 个文件);上述文件不会被提交,请确认是否他人未提交工作。`);
537
560
  }
538
561
  const commitResult = gitCommit(projectRoot, globalArchDir, touchedFiles, changeName, dryRun);
539
562
 
@@ -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
 
@@ -15,10 +15,10 @@
15
15
  // Case B 单仓库:<change-dir> 同级 ../<repo>-<change-name> 存在
16
16
 
17
17
  import { execFileSync } from 'node:child_process';
18
- import { existsSync, readdirSync, rmSync } from 'node:fs';
18
+ import { existsSync, rmSync } from 'node:fs';
19
19
  import { basename, dirname, join, resolve } from 'node:path';
20
20
  import { parseArgs } from 'node:util';
21
- import { detectWorkspaceRoot } from './git-utils.mjs';
21
+ import { detectWorkspaceRoot, listCodeReposDeep } from './git-utils.mjs';
22
22
 
23
23
  export async function run(args) {
24
24
  const { positionals, values } = parseArgs({
@@ -79,24 +79,23 @@ export async function run(args) {
79
79
  }
80
80
 
81
81
 
82
- // Case A: <root>/.worktrees/<change>/<repo>/
82
+ // Case A: <root>/.worktrees/<change>/<repo>/ 或 <root>/.worktrees/<change>/<container>/<repo>/
83
+ // v0.23 §92.3.7(P4 plugin-validator M1):复用两层探测——ensure-branch 现按 relPath 建 worktree,
84
+ // 嵌套布局(service/<repo>)在 <change>/ 下的直接子项是容器目录(无 .git),旧的一层扫描会漏检
85
+ // → deisolate 报 "No worktrees found",closing 阶段代码永远停在 worktree 分支。
83
86
  function detectCaseA(root, changeName) {
84
87
  const changeWorktreeDir = join(root, '.worktrees', changeName);
85
88
  if (!existsSync(changeWorktreeDir)) return null;
86
89
 
87
- let entries;
88
- try {
89
- entries = readdirSync(changeWorktreeDir, { withFileTypes: true });
90
- } catch {
91
- return null;
92
- }
93
-
94
- const repos = entries
95
- .filter(e => e.isDirectory() && existsSync(join(changeWorktreeDir, e.name, '.git')))
96
- .map(e => ({
97
- name: e.name,
98
- worktreePath: join(changeWorktreeDir, e.name),
99
- repoPath: join(root, e.name),
90
+ // excludeNonRepoDirs: false —— worktree 目录结构由 ensure-branch 按 config 权威清单创建,
91
+ // 仓名可能恰是 NON_REPO_DIRS 中的词(data / prototype / docs / …);沿用探测期排除表会漏检
92
+ // 代码静默留在 worktree 分支(P4 plugin-validator 复审残留 M1)。
93
+ const repos = listCodeReposDeep(changeWorktreeDir, { excludeNonRepoDirs: false })
94
+ .map(repo => ({
95
+ // 用 relPath 作展示名:嵌套布局下可区分 service/a 与 service/b(扁平布局等价于目录名)
96
+ name: repo.relPath,
97
+ worktreePath: join(changeWorktreeDir, repo.relPath),
98
+ repoPath: join(root, repo.relPath),
100
99
  layout: 'A',
101
100
  }))
102
101
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -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))) {
@@ -19,7 +19,7 @@
19
19
  import { execFileSync } from 'node:child_process';
20
20
  import { existsSync, mkdirSync, readdirSync, rmdirSync } from 'node:fs';
21
21
  import { join, resolve, isAbsolute } from 'node:path';
22
- import { detectWorkspaceRoot } from './git-utils.mjs';
22
+ import { detectWorkspaceRoot, parsePorcelainPaths } from './git-utils.mjs';
23
23
 
24
24
  const LOCK_PATH = '.publish.lock';
25
25
 
@@ -126,12 +126,9 @@ function detectOutsideDirty(projectRoot, whitelist) {
126
126
  try {
127
127
  const status = git(projectRoot, 'status', '--porcelain');
128
128
  if (!status) return [];
129
- return status.split('\n').filter(Boolean)
130
- .map(line => {
131
- // porcelain 前 2 字符是状态,后面是路径(引号包裹的可能带空格)
132
- const raw = line.slice(3).replace(/^"|"$/g, '').replace(/\\ /g, ' ');
133
- return raw;
134
- })
129
+ // v0.23 §93.3.1:解析统一走 git-utils.parsePorcelainPaths(本文件的 slice(3) 实现
130
+ // 是正确版本,已上移共享;arch-merge 原用的 `^\S+\s+` 正则会漏掉 ` M path`)。
131
+ return parsePorcelainPaths(status)
135
132
  .filter(p => {
136
133
  if (!p) return false;
137
134
  const norm = p.replace(/\/+$/, ''); // porcelain 目录级输出带尾斜杠,先去规范化
@@ -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) {
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from
13
13
  import { homedir } from 'node:os';
14
14
  import path, { join } from 'node:path';
15
15
  import { parseArgs } from 'node:util';
16
+ import { listCodeReposDeep, NON_REPO_DIRS } from './git-utils.mjs';
16
17
 
17
18
  // ── glaf4-dev 运行时探测(v2.1 §4 接口①)─────────────────────────────────────
18
19
 
@@ -109,11 +110,7 @@ export function writeGlaf4DevConfig(root, glaf4DevInfo) {
109
110
 
110
111
  // ── repo_layout 检测(multi-repo-support-design v1.0 §3,2026-08-21)─────────────
111
112
 
112
- // 非代码仓库目录(team-flow 产物/基础设施),扫描时排除
113
- const NON_REPO_DIRS = new Set([
114
- 'changes', '.team-flow', 'docs', 'doc', 'node_modules', '.worktrees',
115
- 'requirement', 'prototype', 'specs', 'data', 'path', 'template',
116
- ]);
113
+ // 非代码仓库目录集合统一在 git-utils.mjs 定义(v0.23 §92:唯一真相源),见顶部 import。
117
114
 
118
115
  /**
119
116
  * 识别项目布局(single / monorepo / multi-repo,v1.0 §3.1)
@@ -130,15 +127,20 @@ export function detectRepoLayout(root) {
130
127
  return { mode: subModules.length > 0 ? 'monorepo' : 'single', repos };
131
128
  }
132
129
  // 2. 根无技术栈特征 → 扫描子目录识别独立代码仓库(含 .git 或有技术栈语言)
130
+ // v0.23 §92.3.3:含 .git 的仓库改用 listCodeReposDeep 探测,支持 `service/<repo>` 两层布局
131
+ //(原实现只扫直接子项 → emp-auth 12 仓被误判 single,config 靠人工修正)。
133
132
  const repos = {};
133
+ for (const repo of listCodeReposDeep(root)) {
134
+ repos[repo.relPath] = repo.absPath;
135
+ }
134
136
  const entries = readdirSync(root, { withFileTypes: true });
135
137
  for (const entry of entries) {
136
138
  if (!entry.isDirectory()) continue;
137
139
  if (entry.name.startsWith('.') || NON_REPO_DIRS.has(entry.name)) continue;
140
+ if (repos[entry.name]) continue;
138
141
  const sub = join(root, entry.name);
139
- const hasGit = existsSync(join(sub, '.git'));
140
142
  const tech = detectTechStack(sub);
141
- if (hasGit || tech.language) {
143
+ if (tech.language) {
142
144
  repos[entry.name] = sub;
143
145
  }
144
146
  }
@@ -5,7 +5,8 @@ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
5
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
- import { detectWorkspaceRoot, findSubRepo } from './git-utils.mjs';
8
+ import { detectWorkspaceRoot, findSubRepo, resolveCodeRepos } from './git-utils.mjs';
9
+ import { loadConfig } from './config-loader.mjs';
9
10
 
10
11
  // v2.1 §6.6:扩入 glaf4-delegation(GLAF4 change 整体委托 glaf4-dev 七模式流,S1 结构统一)
11
12
  export const EXECUTION_MODES = ['inline', 'batch-inline', 'sdd', 'glaf4-delegation'];
@@ -334,8 +335,22 @@ function validateReviewRange(changeDir, base, head, repoPath) {
334
335
  const workspaceRoot = detectWorkspaceRoot(changeDir);
335
336
  if (!workspaceRoot) throw primaryError;
336
337
 
337
- const subRoot = findSubRepo(workspaceRoot, head);
338
- if (!subRoot) throw primaryError;
338
+ // v0.23 §92.3.4:传入 config(repo_layout 权威 + 两层探测)
339
+ const config = loadConfig(workspaceRoot);
340
+ const subRoot = findSubRepo(workspaceRoot, head, config);
341
+ if (!subRoot) {
342
+ // v0.23 §92.3.5:文案区分「SHA 不存在」与「不在已扫描仓中」——后者给出扫描清单与 --repo 指引
343
+ const { repos, unresolved, source } = resolveCodeRepos(workspaceRoot, config);
344
+ const scanned = repos.length ? repos.map(r => r.relPath).join(', ') : '(none)';
345
+ throw new Error(
346
+ `${primaryError.message}\n`
347
+ + ` Scanned repos (source=${source}, ${repos.length}): ${scanned}\n`
348
+ + (unresolved.length
349
+ ? ` Unresolved repo_layout.repos entries (${unresolved.length}): ${unresolved.map(u => u.key).join(', ')}\n`
350
+ : '')
351
+ + ' If the commit lives in a nested repo not listed above, pass --repo <abs-path>.',
352
+ );
353
+ }
339
354
 
340
355
  resolvedBase = resolveGitCommit(subRoot, base, 'base');
341
356
  resolvedHead = resolveGitCommit(subRoot, head, 'head');