harveyz-skill 0.7.0 → 0.9.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.
package/lib/installer.js CHANGED
@@ -53,20 +53,60 @@ export async function installTools(tools, targetDir, force = false) {
53
53
 
54
54
  const destExists = await fs.pathExists(destPath)
55
55
  if (destExists && !force) {
56
+ // Version-aware upgrade logic (mirrors skill behavior)
57
+ const home = os.homedir()
58
+ const dataDir = path.join(home, '.local', 'share', 'hskill', 'tools')
59
+ const installedMeta = path.join(dataDir, `${toolName}.json`)
60
+ const sourceMeta = path.join(srcPath, 'tool.json')
61
+
62
+ let installedVersion = '—'
63
+ let sourceVersion = '—'
64
+ try { installedVersion = (await fs.readJson(installedMeta)).version ?? '—' } catch { /* missing */ }
65
+ try { sourceVersion = (await fs.readJson(sourceMeta)).version ?? '—' } catch { /* missing */ }
66
+
67
+ const isUpToDate = installedVersion !== '—' && sourceVersion !== '—' && installedVersion === sourceVersion
68
+
69
+ if (isUpToDate) {
70
+ skipped.push({ name: toolName, reason: 'up-to-date', version: installedVersion })
71
+ console.error(chalk.dim(` · Skipped ${toolName} (up-to-date ${installedVersion})`))
72
+ continue
73
+ }
74
+
75
+ // Outdated: prompt in TTY, skip in non-TTY
56
76
  if (!process.stdout.isTTY) {
57
- skipped.push({ name: toolName, reason: 'already_exists' })
58
- console.error(chalk.dim(` · Skipped ${toolName} (already exists use --force to overwrite)`))
77
+ skipped.push({ name: toolName, reason: 'outdated', installed: installedVersion, available: sourceVersion })
78
+ console.error(chalk.dim(` · Skipped ${toolName} (outdated ${installedVersion} ${sourceVersion}, use --force to overwrite)`))
59
79
  continue
60
80
  }
61
- const ok = await confirm({ message: `${destPath} already exists. Overwrite?`, default: true })
81
+
82
+ const ok = await confirm({
83
+ message: `${toolName} ${installedVersion} → ${sourceVersion}. Overwrite?`,
84
+ default: true,
85
+ })
62
86
  if (!ok) {
63
- skipped.push({ name: toolName, reason: 'already_exists' })
87
+ skipped.push({ name: toolName, reason: 'outdated', installed: installedVersion, available: sourceVersion })
64
88
  console.error(chalk.dim(` · Skipped ${toolName}`))
65
89
  continue
66
90
  }
67
91
  }
68
92
 
69
93
  try {
94
+ // Clean up uninstallPaths declared in tool.json (e.g. venv) before overwriting
95
+ try {
96
+ const meta = await fs.readJson(path.join(srcPath, 'tool.json'))
97
+ const home = os.homedir()
98
+ for (const p of (meta.uninstallPaths ?? [])) {
99
+ const resolved = p.replace(/^~/, home)
100
+ if (await fs.pathExists(resolved)) {
101
+ await fs.remove(resolved)
102
+ console.error(chalk.green(` ✓ Removed ${p}`))
103
+ }
104
+ }
105
+ if ((meta.uninstallPaths ?? []).length > 0) {
106
+ console.error(chalk.dim(` · venv will be recreated on next launch`))
107
+ }
108
+ } catch { /* tool.json missing or no uninstallPaths — skip silently */ }
109
+
70
110
  const varDefs = await loadVarDefs(srcPath)
71
111
  let varsMap = {}
72
112
  if (varDefs.length > 0) {
@@ -90,13 +130,22 @@ export async function installTools(tools, targetDir, force = false) {
90
130
  const content = await fs.readFile(scriptSrc, 'utf-8')
91
131
  await fs.writeFile(destPath, substituteVars(content, varsMap), { encoding: 'utf-8', mode: 0o755 })
92
132
 
133
+ const dataDir = path.join(os.homedir(), '.local', 'share', 'hskill', 'tools')
134
+
93
135
  const toolJsonSrc = path.join(srcPath, 'tool.json')
94
136
  if (await fs.pathExists(toolJsonSrc)) {
95
- const dataDir = path.join(os.homedir(), '.local', 'share', 'hskill', 'tools')
96
137
  await fs.ensureDir(dataDir)
97
138
  await fs.copy(toolJsonSrc, path.join(dataDir, `${toolName}.json`))
98
139
  }
99
140
 
141
+ // Copy companion Python module (e.g. p_launch.py) when present
142
+ const pyModSrc = path.join(srcPath, `${toolName.replace('-', '_')}.py`)
143
+ if (await fs.pathExists(pyModSrc)) {
144
+ await fs.ensureDir(dataDir)
145
+ await fs.copy(pyModSrc, path.join(dataDir, `${toolName}.py`))
146
+ console.error(chalk.dim(` · Installed ${toolName}.py`))
147
+ }
148
+
100
149
  installed.push(toolName)
101
150
  await _patchZshrc(srcPath, toolName)
102
151
  } catch (err) {
@@ -255,6 +304,53 @@ function _hookCommand(hookName, scope) {
255
304
  : `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/.claude/hooks/${hookName}.sh"`
256
305
  }
257
306
 
307
+ function _codexHooksDir(scope, projectDir) {
308
+ return scope === 'user'
309
+ ? path.join(os.homedir(), '.codex', 'hooks')
310
+ : path.join(projectDir, '.codex', 'hooks')
311
+ }
312
+
313
+ function _codexHooksJsonPath(scope, projectDir) {
314
+ return scope === 'user'
315
+ ? path.join(os.homedir(), '.codex', 'hooks.json')
316
+ : path.join(projectDir, '.codex', 'hooks.json')
317
+ }
318
+
319
+ async function _patchCodexHooks(hooksJsonPath, hook, resolvedCommand, force) {
320
+ let data = { hooks: {} }
321
+ try {
322
+ data = JSON.parse(await fs.readFile(hooksJsonPath, 'utf-8'))
323
+ } catch { /* file doesn't exist yet */ }
324
+
325
+ if (!data.hooks) data.hooks = {}
326
+ if (!data.hooks[hook.event]) data.hooks[hook.event] = []
327
+
328
+ const alreadyRegistered = data.hooks[hook.event].some(entry =>
329
+ Array.isArray(entry.hooks) && entry.hooks.some(h => h.command === resolvedCommand)
330
+ )
331
+
332
+ if (alreadyRegistered && !force) return false
333
+ if (alreadyRegistered && force) {
334
+ data.hooks[hook.event] = data.hooks[hook.event].filter(entry =>
335
+ !Array.isArray(entry.hooks) || !entry.hooks.some(h => h.command === resolvedCommand)
336
+ )
337
+ }
338
+
339
+ // Codex format: no "type" field, absolute path
340
+ const hookEntry = { command: resolvedCommand }
341
+ if (hook.timeout) hookEntry.timeout = hook.timeout
342
+ if (hook.statusMessage) hookEntry.statusMessage = hook.statusMessage
343
+
344
+ data.hooks[hook.event].push({
345
+ matcher: hook.matcher ?? '',
346
+ hooks: [hookEntry],
347
+ })
348
+
349
+ await fs.ensureDir(path.dirname(hooksJsonPath))
350
+ await fs.writeFile(hooksJsonPath, JSON.stringify(data, null, 2) + '\n', 'utf-8')
351
+ return true
352
+ }
353
+
258
354
  async function _patchSettings(settingsPath, hook, scope, force) {
259
355
  let settings = {}
260
356
  try {
@@ -361,6 +457,198 @@ export async function installHooks(hooks, scope, projectDir, force = false) {
361
457
  return { installed, skipped, failed }
362
458
  }
363
459
 
460
+ // Install hooks for a specific target ('claude' or 'codex').
461
+ // Returns { installed, skipped, failed } — same shape as installHooks.
462
+ export async function installHooksForTarget(hooks, target, scope, projectDir, force = false) {
463
+ if (target === 'claude') {
464
+ return installHooks(hooks, scope, projectDir, force)
465
+ }
466
+
467
+ // target === 'codex'
468
+ const hooksDir = _codexHooksDir(scope, projectDir)
469
+ const hooksJsonPath = _codexHooksJsonPath(scope, projectDir)
470
+
471
+ await fs.ensureDir(hooksDir)
472
+
473
+ const installed = []
474
+ const skipped = []
475
+ const failed = []
476
+
477
+ for (const hook of hooks) {
478
+ const destScript = path.join(hooksDir, `${hook.name}.sh`)
479
+ const scriptExists = await fs.pathExists(destScript)
480
+
481
+ if (scriptExists && !force) {
482
+ const installedVersion = readHookVersion(destScript)
483
+ const availableVersion = hook.version
484
+
485
+ if (availableVersion && installedVersion === availableVersion) {
486
+ skipped.push({ name: hook.name, reason: 'up-to-date', version: installedVersion })
487
+ console.error(chalk.dim(` · Skipped ${hook.name} (up-to-date ${installedVersion})`))
488
+ continue
489
+ }
490
+
491
+ if (!process.stdout.isTTY) {
492
+ skipped.push({ name: hook.name, reason: 'outdated', installed: installedVersion, available: availableVersion ?? '—' })
493
+ console.error(chalk.dim(` · Skipped ${hook.name} (outdated ${installedVersion} → ${availableVersion}, use --force)`))
494
+ continue
495
+ }
496
+
497
+ const ok = await confirm({ message: `${hook.name} ${installedVersion} → ${availableVersion}. Overwrite?`, default: false })
498
+ if (!ok) {
499
+ skipped.push({ name: hook.name, reason: 'outdated', installed: installedVersion, available: availableVersion ?? '—' })
500
+ console.error(chalk.dim(` · Skipped ${hook.name}`))
501
+ continue
502
+ }
503
+ }
504
+
505
+ try {
506
+ if (!await fs.pathExists(hook.srcPath)) {
507
+ failed.push({ name: hook.name, reason: 'source_not_found' })
508
+ console.error(chalk.red(` ✗ Source not found: ${hook.srcPath}`))
509
+ continue
510
+ }
511
+
512
+ await fs.copy(hook.srcPath, destScript, { overwrite: true })
513
+ await fs.chmod(destScript, 0o755)
514
+
515
+ const resolvedCommand = destScript // absolute path for Codex
516
+ await _patchCodexHooks(hooksJsonPath, hook, resolvedCommand, force)
517
+
518
+ installed.push(hook.name)
519
+ console.error(chalk.green(` ✓ ${hook.name} → ${destScript}`))
520
+ } catch (err) {
521
+ failed.push({ name: hook.name, reason: 'copy_failed', detail: err.message })
522
+ console.error(chalk.red(` ✗ Failed to install ${hook.name}: ${err.message}`))
523
+ }
524
+ }
525
+
526
+ return { installed, skipped, failed }
527
+ }
528
+
529
+ // ── Tool uninstall ────────────────────────────────────────────────────────────
530
+
531
+ /**
532
+ * Uninstall a shell tool and clean up all associated files.
533
+ * @param {string} toolName
534
+ * @param {{ yes?: boolean }} opts - yes: skip configPaths confirmation
535
+ */
536
+ export async function uninstallTool(toolName, opts = {}) {
537
+ const { yes = false } = opts
538
+ const home = os.homedir()
539
+ const binPath = path.join(home, '.local', 'bin', toolName)
540
+ const dataDir = path.join(home, '.local', 'share', 'hskill', 'tools')
541
+ const jsonPath = path.join(dataDir, `${toolName}.json`)
542
+ const pyPath = path.join(dataDir, `${toolName}.py`)
543
+
544
+ // Check if installed
545
+ if (!await fs.pathExists(binPath) && !await fs.pathExists(jsonPath)) {
546
+ console.error(chalk.dim(` · ${toolName} is not installed`))
547
+ return { removed: [], skipped: [], failed: [] }
548
+ }
549
+
550
+ const removed = []
551
+ const skipped = []
552
+ const failed = []
553
+
554
+ // Read tool.json for extended paths (before removing it)
555
+ let uninstallPaths = []
556
+ let configPaths = []
557
+ try {
558
+ const meta = JSON.parse(await fs.readFile(jsonPath, 'utf-8'))
559
+ uninstallPaths = (meta.uninstallPaths ?? []).map(p => p.replace(/^~/, home))
560
+ configPaths = (meta.configPaths ?? []).map(p => p.replace(/^~/, home))
561
+ } catch { /* tool.json missing or unreadable — proceed with standard paths only */ }
562
+
563
+ // Helper: remove a path (file or dir)
564
+ async function removePath(p) {
565
+ if (!await fs.pathExists(p)) return
566
+ try {
567
+ await fs.remove(p)
568
+ console.error(chalk.green(` ✓ Removed ${p.replace(home, '~')}`))
569
+ removed.push(p)
570
+ } catch (err) {
571
+ console.error(chalk.red(` ✗ Failed to remove ${p.replace(home, '~')}: ${err.message}`))
572
+ failed.push(p)
573
+ }
574
+ }
575
+
576
+ // 1. Standard paths
577
+ await removePath(binPath)
578
+ await removePath(pyPath)
579
+ await removePath(jsonPath)
580
+
581
+ // 2. uninstallPaths (always remove)
582
+ for (const p of uninstallPaths) await removePath(p)
583
+
584
+ // 3. configPaths (ask or skip)
585
+ for (const p of configPaths) {
586
+ if (!await fs.pathExists(p)) continue
587
+ if (yes) {
588
+ await removePath(p)
589
+ } else if (process.stdout.isTTY) {
590
+ const ok = await confirm({
591
+ message: `Remove ${p.replace(home, '~')}? (user config)`,
592
+ default: false,
593
+ })
594
+ if (ok) {
595
+ await removePath(p)
596
+ } else {
597
+ skipped.push(p)
598
+ console.error(chalk.dim(` · Kept ${p.replace(home, '~')}`))
599
+ }
600
+ } else {
601
+ skipped.push(p)
602
+ console.error(chalk.dim(` · Kept ${p.replace(home, '~')} (remove manually if needed)`))
603
+ }
604
+ }
605
+
606
+ // 4. Remove zshrc snippet
607
+ const zshrcPath = path.join(home, '.zshrc')
608
+ const startMarker = `# >>> ${toolName}`
609
+ const endMarker = `# <<< ${toolName}`
610
+ try {
611
+ const content = await fs.readFile(zshrcPath, 'utf-8')
612
+ const startIdx = content.indexOf(startMarker)
613
+ const endIdx = content.indexOf(endMarker)
614
+ if (startIdx !== -1 && endIdx !== -1) {
615
+ const cleaned = content.slice(0, startIdx) + content.slice(endIdx + endMarker.length + 1)
616
+ await fs.writeFile(zshrcPath, cleaned, 'utf-8')
617
+ console.error(chalk.green(` ✓ Removed snippet from ~/.zshrc`))
618
+ removed.push('~/.zshrc snippet')
619
+ }
620
+ } catch { /* .zshrc doesn't exist or not readable */ }
621
+
622
+ return { removed, skipped, failed }
623
+ }
624
+
625
+ /**
626
+ * Uninstall a skill from a specific target directory.
627
+ * @param {string} skillName
628
+ * @param {string} targetDir - full path to target skills dir (e.g. ~/.claude/skills)
629
+ */
630
+ export async function uninstallSkill(skillName, targetDir) {
631
+ const skillPath = path.join(targetDir, skillName)
632
+ const removed = [], skipped = [], failed = []
633
+
634
+ if (!await fs.pathExists(skillPath)) {
635
+ console.error(chalk.dim(` · ${skillName} not installed in ${targetDir.replace(os.homedir(), '~')}`))
636
+ skipped.push(skillName)
637
+ return { removed, skipped, failed }
638
+ }
639
+
640
+ try {
641
+ await fs.remove(skillPath)
642
+ console.error(chalk.green(` ✓ Removed ${skillPath.replace(os.homedir(), '~')}`))
643
+ removed.push(skillName)
644
+ } catch (err) {
645
+ console.error(chalk.red(` ✗ Failed to remove ${skillPath.replace(os.homedir(), '~')}: ${err.message}`))
646
+ failed.push(skillName)
647
+ }
648
+
649
+ return { removed, skipped, failed }
650
+ }
651
+
364
652
  export async function uninstallHook(hookName, scope, projectDir) {
365
653
  const hooksDir = _hooksDir(scope, projectDir)
366
654
  const settingsPath = _settingsPath(scope, projectDir)
package/lib/targets.js CHANGED
@@ -42,3 +42,12 @@ export function resolveTargets(selected, scope = 'user') {
42
42
  return { name, dir: skillDir(name, scope) }
43
43
  })
44
44
  }
45
+
46
+ export const HOOK_TARGETS = ['claude', 'codex']
47
+
48
+ export function buildHookTargetChoices() {
49
+ return [
50
+ { name: `${'claude'.padEnd(8)} (~/.claude/hooks/)`, value: 'claude' },
51
+ { name: `${'codex'.padEnd(8)} (~/.codex/hooks/)`, value: 'codex' },
52
+ ]
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harveyz-skill",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Skill manager for Claude Code, Cursor, and Codex",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "prepack": "node scripts/generate-npmignore.js",
11
- "test": "bats tests/ tools/p-launch/tests/ && bash scripts/run-skill-tests.sh"
11
+ "test": "bats tests/ && cd tools/p-launch && python3 -m pytest tests/test_p_launch.py -v && cd ../.. && bash scripts/run-skill-tests.sh"
12
12
  },
13
13
  "files": [
14
14
  "bin/",
@@ -31,6 +31,7 @@
31
31
  "skills/writing/mermaid-diagram/",
32
32
  "skills/design/sync-design-html/",
33
33
  "skills/analysis/git-cleanup/",
34
+ "skills/meta/contribute-skill/",
34
35
  "tools/p-launch/",
35
36
  "hooks/check-similar-branch/"
36
37
  ],
@@ -2,7 +2,7 @@
2
2
  name: git-workflow-init
3
3
  description: 初始化或更新 git 分支管理规范:读取项目的 workflow-config.yml,审核配置,差量生成并部署 git hooks(pre-commit、commit-msg、pre-push、post-checkout),生成工作流文档,可选写入 AI 配置文件引用。支持差量更新、MANAGED 块 hash 校验(检测用户手改)、lock 文件 diff(检测配置变更)、conflict scanner(检测用户代码与新配置的交叉冲突)。触发时机:初始化新 git 仓库、新项目首次配置 git、用户要求设置/更新分支保护或分支规范、安装或重新部署 git hooks、skill 或模板更新后需要同步、或问到分支命名规范。只要项目需要配置或更新 git 工作流,就应使用此 skill。
4
4
  user_invocable: true
5
- version: "4.0.0"
5
+ version: "4.1.0"
6
6
  ---
7
7
 
8
8
  # Git 工作流初始化
@@ -66,114 +66,33 @@ echo "" | grep -E "<pattern>" > /dev/null 2>&1; echo $?
66
66
 
67
67
  **首次安装时跳过此步骤,直接进入 Step 5。**
68
68
 
69
- 此步骤目标:在写入任何文件之前,全面了解当前状态,收集所有需要用户决策的冲突,在 Step 5 一次性呈现。
69
+ 目标:写入任何文件之前,全面收集需要用户决策的冲突,在 Step 5 一次性呈现。实现细节见 `references/conflict-analysis.md`。
70
70
 
71
- #### 4a. Lock 文件 diff — 配置发生了什么变化
71
+ #### 4a. Lock 文件 diff
72
+ 读取 `.githooks/.workflow-config.lock.yml`(不存在则视为全量新增,跳过)。对比上次配置与当前配置,生成 +/~/- 变更摘要。**提取 YAML 分支名时必须用 python3 精确解析,不得用 `grep "name:"`**(见 reference 中的脚本)。
72
73
 
73
- 读取 `.githooks/.workflow-config.lock.yml`(若不存在则视为全量新增,跳过 diff)。
74
+ #### 4b. MANAGED 块 hash 校验
75
+ 读取每个 hook 的 `BEGIN MANAGED`/`END MANAGED` 块,重新计算 hash,与块头标记对比。不一致 → 用户手改,记录为类型 C 冲突(附 diff)。
74
76
 
75
- lock 文件中记录的上一次配置与当前配置对比,生成变更摘要:
77
+ #### 4c. 外部代码扫描
78
+ 提取每个 hook 中 MANAGED 块外的用户手写代码,识别其中引用的分支名、提交类型、pattern、外部脚本调用。
76
79
 
77
- ```
78
- 配置变更摘要:
79
- + branches.protected 新增: develop(merge_from: feature/*)
80
- ~ commit_message.conventional.types 修改: 新增 wip,移除 perf
81
- - tags.allowed_patterns 删除: ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$
82
- ```
83
-
84
- **YAML 分支名提取规则(重要):**
85
-
86
- 不要用宽泛的 `grep "name:"` 提取分支名——YAML 多个层级都可能含有 `name:` 字段,会导致误匹配。用 `python3` 精确解析,进入 `protected:` 块后再匹配 `- name:`,遇到非缩进行则停止:
87
-
88
- ```bash
89
- # FILE 传入要解析的文件路径(workflow-config.yml 或 lock 文件均适用)
90
- python3 -c "
91
- import re, sys
92
- content = open(sys.argv[1]).read()
93
- in_protected = False
94
- for line in content.splitlines():
95
- if 'protected:' in line:
96
- in_protected = True; continue
97
- if in_protected:
98
- m = re.match(r'\s+- name:\s+(\S+)', line)
99
- if m: print(m.group(1))
100
- elif line.strip() and not line.startswith(' '): break
101
- " "$FILE"
102
- ```
103
-
104
- 这份摘要用于后续冲突检测的输入。
105
-
106
- #### 4b. MANAGED 块 hash 校验 — 用户是否手改了生成的代码
107
-
108
- 读取每个 hook 文件,提取所有 `BEGIN MANAGED` / `END MANAGED` 块。
109
-
110
- MANAGED 块格式:
111
- ```sh
112
- # --- BEGIN MANAGED: <block-id> (hash:<8位hex>) ---
113
- <generated content>
114
- # --- END MANAGED: <block-id> ---
115
- ```
116
-
117
- 对每个块:重新计算块内容(两行标记之间的部分,去首尾空白)的 hash:
118
- ```bash
119
- actual_hash=$(printf '%s' "<block_content>" | git hash-object --stdin | cut -c1-8)
120
- ```
121
-
122
- 若 `actual_hash` 与标记里记录的 hash 不一致 → 用户手改了此块,记录为冲突:
123
-
124
- ```
125
- [手改冲突] .githooks/pre-commit 的 MANAGED 块 branches.protected/main 被手动修改
126
- 原始生成内容 hash: a3f9c2b1
127
- 当前内容 hash: d7e42f0c
128
- 差异:(展示 diff)
129
- ```
130
-
131
- #### 4c. 外部代码扫描 — 用户在 MANAGED 块外添加了什么
132
-
133
- 提取每个 hook 文件中 MANAGED 块之外的所有代码(即用户手写区)。
134
-
135
- 从用户代码中提取可解析的引用:
136
-
137
- | 提取目标 | 扫描方式 |
138
- |---------|---------|
139
- | 分支名 | `"$BRANCH" = "name"`、`case "name"`、`branch = "name"` |
140
- | 提交类型 | `grep -qE "...(type\|...)"`、字符串字面量 `wip`、`hotfix` |
141
- | Tag/分支 pattern | `grep -qE "pattern"` |
142
- | 外部脚本调用 | `./`、`bash `、`sh `、`npm run`、`make ` 开头的行 |
143
-
144
- #### 4d. 冲突检测 — 用户代码 × 新配置的交叉分析
145
-
146
- 综合 4a(配置变更)、4b(手改块)、4c(用户代码)的结果,识别以下冲突类型:
147
-
148
- **冲突类型 A:用户代码覆盖了与新配置相同的条件**
149
- - 用户代码处理了分支 X,新配置也将 X 加入 `branches.protected`
150
- - 两段代码同时执行,可能产生矛盾行为
151
-
152
- **冲突类型 B:用户代码引用了配置中已删除的内容**
153
- - 用户代码里有对类型 `wip` 的处理,新配置删除了 `wip`
154
- - 用户代码逻辑失去对应的配置支撑
155
-
156
- **冲突类型 C:MANAGED 块被手动修改**
157
- - 来自 4b 的结果
158
- - 重新生成会覆盖用户修改
159
-
160
- **冲突类型 D:用户代码引用了新配置新增的内容(信息提示,非阻断)**
161
- - 用户已手写了对 `develop` 的处理,新配置也打算生成同名 MANAGED 块
162
- - 不一定冲突,但值得用户确认
80
+ #### 4d. 冲突检测
81
+ 综合 4a/4b/4c,识别四种冲突类型(A 条件重叠、B 引用断裂、C 手改冲突、D 新增重叠)。类型定义与选项见 `references/conflict-analysis.md`。
163
82
 
164
83
  ---
165
84
 
166
85
  ### Step 5 — 冲突解决(一次性汇总,用户逐条决策)
167
86
 
168
- 将 Step 4 发现的所有冲突汇总后**一次性呈现**,每条冲突附带建议选项。
87
+ 将 Step 4 发现的所有冲突**一次性呈现**,每条附带类型标签和选项,用户逐条决策后进入 Step 6。无冲突则直接进入 Step 6。
169
88
 
170
- 示例输出:
89
+ 呈现格式(以类型 C 手改冲突为例):
171
90
 
172
91
  ```
173
- 发现 3 处需要决策的冲突,请逐条确认:
92
+ 发现 N 处需要决策的冲突,请逐条确认:
174
93
 
175
94
  ─────────────────────────────────────────────────────────
176
- [1/3] 手改冲突(类型 C)
95
+ [1/N] 手改冲突(类型 C)
177
96
  文件: .githooks/pre-commit,块: branches.protected/main
178
97
  用户对生成代码做了如下修改:
179
98
  - echo "❌ 禁止直接在 main 上提交。"
@@ -182,26 +101,9 @@ actual_hash=$(printf '%s' "<block_content>" | git hash-object --stdin | cut -c1-
182
101
  A) 保留用户修改(此块不重新生成)
183
102
  B) 用新配置覆盖(丢弃用户修改)
184
103
  ─────────────────────────────────────────────────────────
185
- [2/3] 条件重叠(类型 A)
186
- 文件: .githooks/pre-commit,用户代码第 58 行
187
- 用户手写了 develop 分支的保护逻辑
188
- 新配置将 develop 加入 branches.protected,也会生成对应 MANAGED 块
189
- 选项:
190
- A) 保留用户代码,跳过生成 develop 的 MANAGED 块
191
- B) 用新配置生成 MANAGED 块,删除用户手写的重复代码
192
- C) 两者都保留(会重复执行,请确认逻辑不矛盾)
193
- ─────────────────────────────────────────────────────────
194
- [3/3] 引用断裂(类型 B)
195
- 文件: .githooks/commit-msg,用户代码第 34 行
196
- 用户代码引用了 commit 类型 wip,但新配置已从类型列表中移除 wip
197
- 选项:
198
- A) 保留用户代码(wip 检查逻辑继续生效,但配置层不再管理)
199
- B) 删除用户代码中对 wip 的引用
200
- ─────────────────────────────────────────────────────────
201
104
  ```
202
105
 
203
- 用户全部决策完成后,带着决策结果进入 Step 6。
204
- 若无任何冲突,直接进入 Step 6。
106
+ 各冲突类型的完整选项定义见 `references/conflict-analysis.md`。
205
107
 
206
108
  ---
207
109
 
@@ -237,6 +139,19 @@ git config merge.ff false
237
139
 
238
140
  ---
239
141
 
142
+ ### Step 6.5 — 验收测试(可选)
143
+
144
+ 完成 hooks 部署后,询问用户:
145
+
146
+ ```
147
+ ✅ Hooks 已部署完毕。是否运行验收测试?(在当前 repo 中验证 hooks 实际拦截行为)[y/N]
148
+ ```
149
+
150
+ - 用户选 **N** → 直接进入 Step 7。
151
+ - 用户选 **Y** → 读取 `references/acceptance-test.md`,按其中说明逐 hook 执行验收场景,展示结果表格。全通过则继续 Step 7;有失败则展示详情并询问用户是否仍继续。
152
+
153
+ ---
154
+
240
155
  ### Step 7 — 从配置渲染并写入工作流文档(差量)
241
156
 
242
157
  每次运行都根据当前 `workflow-config.yml` 的最终状态重新渲染文档,确保文档与配置始终一致。
@@ -315,7 +230,9 @@ CLAUDE.md 已有引用,跳过
315
230
  | 文件 | 说明 | 读取时机 |
316
231
  |------|------|---------|
317
232
  | `references/workflow-config.yml` | 配置文件模板 | Step 2:用户无配置时复制 |
233
+ | `references/conflict-analysis.md` | 4a–4d 实现脚本、冲突类型 A/B/C/D 完整定义与选项、Step 5 呈现格式 | Step 4/5:执行分析前读取 |
318
234
  | `references/hook-templates.md` | 4 个 hook 的代码模板、块 ID 规范、hash 计算、多行写入技巧 | Step 6:生成 hooks 前 |
235
+ | `references/acceptance-test.md` | 验收场景 bash 脚本、期望结果、结果表格格式 | Step 6.5:用户选 Y 时读取 |
319
236
  | `references/lock-file-format.md` | lock 文件 YAML 格式 | Step 9:写入 lock 文件前 |
320
237
  | `references/git-workflow-template.md` | 工作流文档模板(含占位符) | Step 7:由 render_docs.py 读取 |
321
238
  | `references/render_docs.py` | 文档渲染脚本 | Step 7:直接执行 |