kld-sdd 2.7.8-4 → 2.7.8-6

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/README.md CHANGED
@@ -6,6 +6,30 @@ KLD SDD OpenSpec 工程增强工具:一键初始化 AI 编辑器技能、语
6
6
 
7
7
  日常提交默认只保留本地关联 Hook;Apply 自动执行代码/Spec 检查、真实测试与交付验证。旧项目升级、备份回退、多仓和 CI 边界见 [Git Hook 调整说明](USABILITY.md#2026-09-10git-hook-收缩与交付验证)。
8
8
 
9
+ ## 工具更新与卸载
10
+
11
+ **更新不需要先卸载。** 在原工作目录重新运行安装器,覆盖运行脚本、Skills 和只读模板;Spec 文档、KB 绑定、项目身份、历史数据、用户配置和自定义 Hook 保留。先确认目标编辑器及 Spec 目录,完成后检查实际部署版本。下载失败不启动安装;执行中断后可以重跑补齐,不承诺整个项目的事务回滚。
12
+
13
+ ```bash
14
+ # 选择包含本次修复的新发布版本,在原工作目录更新
15
+ npx kld-sdd@<新版版本号> --tool=cursor --spec-path=team-sdd-specs --skip-openspec
16
+
17
+ # 卸载只有工具一种方式;可以先看计划
18
+ npx kld-sdd@<新版版本号> uninstall --dry-run
19
+ npx kld-sdd@<新版版本号> uninstall
20
+ ```
21
+
22
+ 卸载不再提供全量删除,旧 `--purge` 参数会报错退出。`openspec/`(含活动变更、归档和规格)、KB 配置、身份、历史数据、`.sdd.yaml`、`.sdd-spec-root`、`.sdd-workspace.yaml`、Git 关联和隐私忽略规则始终保留。工具目录内的未知文件和自建规则也保留;Git Hook 只有完整匹配受管版本时才删除。预览后文件改变需要重新预览。
23
+
24
+ 多仓注意:
25
+
26
+ - 升级沿用已有清单,只维护登记的代码仓;多个 Spec 和备份不会互相接入。新代码仓通过 `kld-sdd sync-repos` 明确接入,追加清单时保留注释、自定义字段和原有记录。
27
+ - 本地路径、团队远程声明或工作区清单冲突时,先停止更新并提示核对,不自动换绑。
28
+ - 从单个代码仓卸载时,工作目录外的共享 Spec 工具保留。从 worktree 卸载时,共用的 Git Hook 保留,应从主仓库管理。
29
+ - 旧平铺 `skywalk-sdd/` 只清理已知工具,历史数据原位保留,不自动合并两份项目身份或运行状态。
30
+
31
+ 以上行为需要使用包含本次改动的新版本;修改此源码不会改变已经发布的 npm 包。安装包通过 `sddMaintenance.toolOnly: 1` 声明该维护契约,托盘据此避免用旧安装器处理存在已知清理风险的布局。
32
+
9
33
  ## 这是什么?
10
34
 
11
35
  **SDD(Specification-Driven Development)** 是一种以文档链驱动 AI 编码的研发方法:先写清楚"要做什么",再让 AI 去实现,避免 AI 乱猜、反复返工。
@@ -12,7 +12,7 @@
12
12
  * log SDD Telemetry CLI(记录阶段事件、查询指标)
13
13
  * link-spec 将代码仓关联到本地 spec clone(git config sdd.specPath)
14
14
  * sync-repos 工作目录下发现新增代码仓并轻量接入(不装 skills)
15
- * uninstall 卸载 SDD 产物(渐进式:先选方式,再预览计划,最后确认执行)
15
+ * uninstall 卸载 SDD 产物(渐进式:先预览工具范围,最后确认执行)
16
16
  */
17
17
 
18
18
  'use strict';
@@ -20,7 +20,8 @@
20
20
  const path = require('path');
21
21
  const { listToolProfileIds, buildToolConfigs } = require('../lib/tool-profiles');
22
22
  const { MODE, OP, modeLabel } = require('../lib/uninstall');
23
- const { specSourceLabel } = require('../skywalk-sdd/ontology/spec-detect.cjs');
23
+ const specDetect = require('../skywalk-sdd/ontology/spec-detect.cjs');
24
+ const { specSourceLabel } = specDetect;
24
25
  // spec 候选单选交互(唯一实现,与 lib/init.js 共用)
25
26
  const { selectSpecRoot: selectSpecChoice } = require('../lib/spec-choice');
26
27
 
@@ -125,6 +126,9 @@ function parseInitArgs(argv = cliArgs(), env = process.env) {
125
126
  verbose: isVerbose(argv, env),
126
127
  skipOpenspec: argv.includes('--skip-openspec'),
127
128
  skipTemplate: argv.includes('--skip-template'),
129
+ repairGit: argv[0] === 'repair-git' || argv.includes('--repair-git'),
130
+ applyRepair: argv.includes('--apply'),
131
+ json: argv.includes('--json'),
128
132
  tools,
129
133
  // 显式给了 --tool 即自动化场景:lib 据此抑制 spec 路径的交互询问
130
134
  hasToolArg: tools !== null,
@@ -136,19 +140,19 @@ function parseInitArgs(argv = cliArgs(), env = process.env) {
136
140
  * kld-sdd uninstall [选项]
137
141
  *
138
142
  * 卸载产物与编辑器无关(8 个编辑器的产物全量扫描),工作目录固定为当前目录,
139
- * 故不提供 --tool / --project:卸载范围由「方式选择」决定,目录由 cwd 决定。
143
+ * 故不提供 --tool / --project:仅卸载当前范围的工具,目录由 cwd 决定。
140
144
  *
141
145
  * --spec-path 用于无人值守场景:多候选(如用户复制的备份)时无法自动判定,
142
146
  * 需显式指定要卸载的 spec 仓库。
143
147
  */
144
148
  function parseUninstallArgs(argv = cliArgs()) {
145
- const purge = argv.includes('--purge');
149
+ if (argv.some(arg => arg === '--purge' || arg.startsWith('--purge=') || arg === '--full' || arg.startsWith('--mode'))) {
150
+ throw new Error('已取消全量删除,仅支持卸载工具;Spec 文档、配置与历史数据始终保留');
151
+ }
146
152
  return {
147
153
  cwd: process.cwd(),
148
154
  specPath: parseSpecPathOption(argv),
149
- mode: purge ? MODE.FULL : MODE.TOOLS_ONLY,
150
- // 是否显式指定了方式:未指定且未跳过交互时,进入渐进式方式选择
151
- modeExplicit: purge,
155
+ mode: MODE.TOOLS_ONLY,
152
156
  dryRun: argv.includes('--dry-run'),
153
157
  yes: hasFlag(argv, '--yes', '-y'),
154
158
  help: hasFlag(argv, '-h', '--help'),
@@ -325,78 +329,26 @@ async function selectSpecRoot(prompt, candidates, options = {}) {
325
329
  return selectSpecChoice(prompt, candidates, options);
326
330
  }
327
331
 
328
- /**
329
- * 渐进式选择卸载方式。
330
- * 默认「1 卸载工具内容」为保守选项,回车即安全;无效输入重新询问。
331
- *
332
- * @returns {Promise<string|null>} MODE 之一;null 表示用户取消
333
- */
334
- async function selectUninstallMode(prompt) {
335
- while (true) {
336
- // 选项文本并入 question:readline 重绘提示行会发 \x1b[0J(清除光标之后内容),
337
- // 提问前的 console.log 会被抹掉,导致用户看不到选项。
338
- const question = [
339
- '请选择卸载方式:',
340
- ' 1. 卸载工具内容(默认,保留用户配置与运行时数据)',
341
- ' 删除 编辑器 skills / hook 脚本 / skywalk-sdd 工具脚本 / 文档模版',
342
- ' 保留 modules.yaml、sdd.config.yaml、.sdd.yaml、events/、state/',
343
- ' 2. 全量卸载(含用户配置、运行时数据与文档工作区)',
344
- ' 在「1」的基础上,额外删除上述用户配置、历史度量数据与 openspec/ 整目录',
345
- ' ⚠️ openspec/ 内含 changes/ 变更提案与 specs/ 业务规格文档,将一并删除',
346
- ' 0. 取消',
347
- '\n请输入选项 (0-2,直接回车=1): ',
348
- ].join('\n');
349
-
350
- const answer = await prompt.ask(question);
351
- if (answer === '' || answer === '1') return MODE.TOOLS_ONLY;
352
- if (answer === '2') return MODE.FULL;
353
- if (answer === '0' || /^(q|quit|exit)$/i.test(answer)) return null;
354
- console.log(`❌ 无效选项 "${answer}",请输入 0-2 之间的数字\n`);
355
- }
356
- }
357
-
358
332
  /** 显示卸载帮助信息 */
359
333
  function showUninstallHelp() {
360
334
  console.log(`
361
- KLD SDD 卸载工具
335
+ KLD SDD 工具卸载
362
336
 
363
337
  用法:
364
- kld-sdd uninstall # 渐进式交互(推荐):选方式预览计划 → 确认
365
- kld-sdd uninstall [选项] # 非交互:显式给出方式与确认
338
+ kld-sdd uninstall # 选择 Spec 预览范围 → 确认
339
+ kld-sdd uninstall --dry-run # 只看计划,不修改文件
340
+ kld-sdd uninstall --yes --spec-path <dir> # 明确范围后用于脚本
366
341
 
367
- 选项:
368
- -h, --help 显示本帮助
369
- --purge 全量卸载(含用户配置与运行时数据);不指定则只卸载工具内容
370
- --dry-run 预演,打印计划预览但不落盘(不进入交互)
371
- --spec-path <dir> 显式指定要卸载的 spec 仓库(无人值守时多候选必须指定)
372
- -y, --yes 跳过最后的执行确认(CI / 脚本用)
373
-
374
- 说明:
375
- 卸载在「当前目录」执行,产物范围为全部 8 个编辑器(cursor, claude, codebuddy,
376
- qoder, opencode, kunlunzhima, workbuddy, codex),无需也无法指定编辑器。
377
- 卸载范围由方式选择(工具内容 / 全量)决定。需在目标项目目录下执行本命令。
378
-
379
- 交互流程:spec 仓库选择 → 卸载方式选择 → 计划预览 → 确认执行。
380
- 若检测到多个 spec 仓库(例如您手动复制了一份备份),会列出候选让您单选;
381
- 无人值守(--yes / 无 TTY)时多候选会直接报错,请用 --spec-path 指定。
382
-
383
- 两种方式的区别:
384
- 工具内容卸载(默认):
385
- 删除 编辑器 skills(opsx-* / tdd-* / openspec-*)、sdd-*.cjs hook 脚本、
386
- hook-gate-core.cjs、skywalk-sdd/ 工具脚本、openspec-templates/、操作手册
387
- 保留 settings.json 文件本身(仅摘除 SDD 受管 hook)、modules.yaml、
388
- sdd.config.yaml、.sdd.yaml、.sdd-spec-root、skywalk-sdd/events/ 与 state/、
389
- openspec/ 文档工作区
390
- 全量卸载(--purge):
391
- 在「工具内容卸载」基础上,额外删除上述保留项中的用户配置、历史度量数据,
392
- 以及 openspec/ 整目录(含 changes/ 变更提案、specs/ 业务规格与 overview.md、
393
- config.yaml)。执行前会打印待删目录与文件数,需二次确认。
342
+ 仅删除可识别的 SDD Skills、运行脚本、只读模板与受管 Hook。
343
+ 始终保留 openspec/ 全部文档、KB 配置、项目身份、历史数据、多仓关联和 Git 历史。
344
+ 自定义 Hook、规则、未知文件与隐私忽略规则保留。不提供全量删除模式。
394
345
 
395
- 示例:
396
- kld-sdd uninstall # 交互式,最常用(在项目目录下执行)
397
- kld-sdd uninstall --dry-run # 只看计划
398
- kld-sdd uninstall --purge --dry-run # 全量预演
399
- kld-sdd uninstall --purge --yes # 全量执行,不询问
346
+ -h, --help 显示本帮助
347
+ --spec-path <dir> 指定目标 Spec;多个候选时必须选择,备份不受影响
348
+ --dry-run 只预览
349
+ -y, --yes 确认工具卸载;非交互执行必须提供
350
+
351
+ 升级不需要先卸载:重新运行 init 覆盖工具文件,保留已有文档及配置。
400
352
  `);
401
353
  }
402
354
 
@@ -409,8 +361,9 @@ KLD SDD 项目初始化工具
409
361
  kld-sdd-init [选项]
410
362
  npx kld-sdd [选项]
411
363
  kld-sdd sync-repos # 工作目录下发现新增代码仓并轻量接入(多 spec 候选时单选)
412
- kld-sdd uninstall [选项] # 卸载 SDD 产物(渐进式:选方式 → 预览 → 确认)
413
- kld-sdd uninstall --help # 查看卸载选项与两种方式的区别
364
+ kld-sdd repair-git # 检查并清理旧 Spec 仓的工具跟踪噪声(默认只预览)
365
+ kld-sdd uninstall [选项] # 卸载 SDD 产物(渐进式:预览工具范围 → 确认)
366
+ kld-sdd uninstall --help # 查看工具卸载与保留范围
414
367
 
415
368
  选项:
416
369
  -h, --help 显示帮助信息
@@ -418,6 +371,9 @@ KLD SDD 项目初始化工具
418
371
  --verbose 输出每个安装文件/目录的明细日志(默认仅摘要,亦可用 KLD_SDD_VERBOSE=1)
419
372
  --skip-openspec 跳过 openspec init 步骤
420
373
  --skip-template 跳过复制内置模版
374
+ --repair-git 检查 Spec 仓中被 Git 跟踪的安装器工具(默认只预览)
375
+ --apply 与 --repair-git 同用:保留本地文件,仅从 Git 索引移除工具
376
+ --json --repair-git 输出机器可读 JSON,供托盘健康检查使用
421
377
  --tool <name> 指定编辑器,可用值: cursor, claude, codebuddy, qoder, opencode, kunlunzhima, workbuddy, codex, all
422
378
  --spec-path <dir> 指定 spec 包裹包目录(必须是当前目录的子目录)。
423
379
  不指定时(交互终端):自动检测已有 spec 目录(目录名 *-sdd-specs,
@@ -437,6 +393,8 @@ KLD SDD 项目初始化工具
437
393
  kld-sdd-init --skip-openspec # 跳过 openspec,直接部署 skills
438
394
  kld-sdd-init --tool codex # 仅部署 Codex 配置
439
395
  kld-sdd-init --spec-path erp-sdd-specs # 指定 spec 目录(当前目录的子目录)
396
+ kld-sdd repair-git --spec-path=. # 预览旧仓工具跟踪问题
397
+ kld-sdd repair-git --apply --spec-path=. # 一次迁出工具,文件仍留在磁盘
440
398
  kld-sdd sync-repos # 新代码仓加入后同步接入
441
399
  `);
442
400
  }
@@ -445,6 +403,72 @@ KLD SDD 项目初始化工具
445
403
  // 子命令路由:解析参数后调用 lib/ 业务实现
446
404
  // ═══════════════════════════════════════════════════════════════
447
405
 
406
+ function resolveRepairSpecRoot(parsed, cwd = process.cwd()) {
407
+ const root = path.resolve(cwd);
408
+ if (parsed.specPath) {
409
+ const explicit = path.resolve(root, parsed.specPath);
410
+ if (!specDetect.isSpecPackageRoot(explicit)) {
411
+ throw new Error(`指定目录不是有效的 Spec 包裹包: ${explicit}`);
412
+ }
413
+ return explicit;
414
+ }
415
+ if (specDetect.isSpecPackageRoot(root)) return root;
416
+ const candidates = specDetect.detectSpecCandidatesWithGit(root);
417
+ if (candidates.length === 1) return candidates[0].abs;
418
+ if (candidates.length === 0) throw new Error('未发现 Spec 包裹包,请用 --spec-path=<dir> 指定');
419
+ throw new Error(`发现 ${candidates.length} 个 Spec 包裹包,请用 --spec-path=<dir> 指定,不自动猜测`);
420
+ }
421
+
422
+ function runGitRepair(parsed) {
423
+ const init = require('../lib/init');
424
+ const maintenance = require('../lib/maintenance');
425
+ const specRoot = resolveRepairSpecRoot(parsed);
426
+ const plan = maintenance.trackedManagedSpecTools(specRoot);
427
+ if (!plan.ok) throw new Error(plan.error);
428
+
429
+ if (!parsed.applyRepair) {
430
+ const payload = {
431
+ mode: 'preview',
432
+ specRoot,
433
+ trackedToolCount: plan.files.length,
434
+ trackedTools: plan.files.map((file) => file.relative),
435
+ businessFilesAffected: 0,
436
+ };
437
+ if (parsed.json) {
438
+ console.log(JSON.stringify(payload, null, 2));
439
+ return;
440
+ }
441
+ console.log(`🧹 Spec Git 清洁度检查: ${path.basename(specRoot)}`);
442
+ console.log(` 被 Git 跟踪的可再生成工具: ${plan.files.length} 个`);
443
+ for (const file of plan.files.slice(0, 8)) console.log(` - ${file.relative}`);
444
+ if (plan.files.length > 8) console.log(` … 其余 ${plan.files.length - 8} 个`);
445
+ console.log(' 业务 Spec、配置、项目身份与 Hook 参数: 不处理');
446
+ if (plan.files.length) console.log(' 执行 --repair-git --apply 可一次迁出工具;不会删除磁盘文件。');
447
+ return;
448
+ }
449
+
450
+ const existingBefore = plan.files.filter((file) => require('fs').existsSync(path.join(specRoot, file.relative)));
451
+ init.updateGitignore(specRoot, { specPackage: true });
452
+ const result = maintenance.untrackManagedSpecTools(specRoot);
453
+ if (!result.ok) throw new Error(result.error);
454
+ const payload = {
455
+ mode: 'applied',
456
+ specRoot,
457
+ untrackedToolCount: result.files.length,
458
+ filesRemainOnDisk: existingBefore.every((file) => require('fs').existsSync(path.join(specRoot, file.relative))),
459
+ retainedLocalToolCount: existingBefore.length,
460
+ alreadyMissingToolCount: result.files.length - existingBefore.length,
461
+ businessFilesAffected: 0,
462
+ };
463
+ if (parsed.json) {
464
+ console.log(JSON.stringify(payload, null, 2));
465
+ return;
466
+ }
467
+ console.log(`✅ 已将 ${result.files.length} 个安装器工具移出 Git 跟踪,现存的 ${existingBefore.length} 个本地文件全部保留`);
468
+ if (payload.alreadyMissingToolCount) console.log(` ℹ️ 其中 ${payload.alreadyMissingToolCount} 个文件在修复前已从磁盘删除`);
469
+ console.log(' 请审阅一次 git status 并提交迁移;以后升级不再污染业务改动列表。');
470
+ }
471
+
448
472
  async function runInit() {
449
473
  let parsed;
450
474
  try {
@@ -464,6 +488,15 @@ async function runInit() {
464
488
  return;
465
489
  }
466
490
 
491
+ if (parsed.repairGit) {
492
+ try {
493
+ runGitRepair(parsed);
494
+ } catch (error) {
495
+ fail(`Git 清洁度修复失败: ${error.message}`);
496
+ }
497
+ return;
498
+ }
499
+
467
500
  const init = require('../lib/init');
468
501
  init.setVerbose(parsed.verbose);
469
502
 
@@ -484,7 +517,7 @@ async function runInit() {
484
517
  /**
485
518
  * 卸载(渐进式):方式选择 → 计划预览 → 确认执行。
486
519
  *
487
- * 交互编排全部在本函数(CLI 输入层);lib/uninstall 只提供纯计算与执行:
520
+ * 交互编排全部在本函数(CLI 输入层);仅选择 Spec、预览工具范围并确认:
488
521
  * - buildUninstallPlan 纯计算计划,不落盘
489
522
  * - main 按给定计划执行,无交互
490
523
  *
@@ -524,7 +557,7 @@ async function runUninstall(argv = args, deps = {}) {
524
557
  // 候选枚举:结构判据无法区分「当前项目的 spec 仓」与「用户复制的备份」
525
558
  //(如 `erp-sdd-specs copy` 结构标记齐全),故由用户单选裁决。
526
559
  // 显式 --spec-path 时跳过枚举(调用方已指定)。
527
- let specRoot = opts.specPath ? path.resolve(opts.specPath) : undefined;
560
+ let specRoot = opts.specPath ? path.resolve(cwd, opts.specPath) : undefined;
528
561
  const candidates = specRoot ? [] : listSpecCandidatesForUninstall(cwd);
529
562
 
530
563
  if (!specRoot && candidates.length > 1 && !canPrompt) {
@@ -539,6 +572,7 @@ async function runUninstall(argv = args, deps = {}) {
539
572
  throw new Error('多个 spec 仓库候选,需 --spec-path 显式指定');
540
573
  }
541
574
 
575
+ if (!specRoot && !canPrompt && candidates.length === 1) specRoot = candidates[0].abs;
542
576
  const prompt = canPrompt ? promptFactory() : null;
543
577
 
544
578
  // 步骤 1:spec 仓库选择(多候选 → 用户单选;单候选 → 确认)
@@ -563,31 +597,15 @@ async function runUninstall(argv = args, deps = {}) {
563
597
  }
564
598
 
565
599
  if (!prompt) {
600
+ if (!dryRun && !opts.yes) throw new Error('非交互卸载需要 --yes;可先用 --dry-run 预览');
566
601
  printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
567
602
  if (dryRun) printPlanPreview(planned.plan);
568
603
  // 已打印过预览(含警告)时不重复;直接执行时由 lib 打印
569
- runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: !dryRun });
604
+ const result = runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: !dryRun });
605
+ if (!result.ok) throw new Error(result.errors.join('; '));
570
606
  return;
571
607
  }
572
608
 
573
- // 步骤 3:方式选择(已显式 --purge 则跳过)
574
- if (!opts.modeExplicit) {
575
- const chosen = await selectUninstallMode(prompt);
576
- if (chosen === null) {
577
- console.log('已取消');
578
- return;
579
- }
580
- if (chosen !== mode) {
581
- mode = chosen;
582
- planned = buildUninstallPlan({ cwd, mode, specRoot });
583
- if (planned.plan.ops.length === 0) {
584
- printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
585
- console.log('ℹ️ 未发现需要处理的 SDD 产物(可能未初始化或已卸载)');
586
- return;
587
- }
588
- }
589
- }
590
-
591
609
  // 步骤 4:计划预览
592
610
  printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
593
611
  printPlanPreview(planned.plan);
@@ -602,7 +620,8 @@ async function runUninstall(argv = args, deps = {}) {
602
620
  console.log('');
603
621
 
604
622
  // 交互路径:警告已由 printPlanPreview 打印过,不再重复
605
- runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: false });
623
+ const result = runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: false });
624
+ if (!result.ok) throw new Error(result.errors.join('; '));
606
625
  } finally {
607
626
  if (prompt) prompt.close();
608
627
  }
@@ -724,7 +743,6 @@ module.exports = {
724
743
  runUninstall,
725
744
  parseUninstallArgs,
726
745
  printPlanPreview,
727
- selectUninstallMode,
728
746
  selectSpecRoot,
729
747
  showUninstallHelp,
730
748
  };
package/lib/init.js CHANGED
@@ -31,6 +31,7 @@ const {
31
31
  const { deployCodebuddyHookPack } = require('./deploy-codebuddy-hooks');
32
32
  const { PROVIDERS, computeMergedSettings } = require('./settings-merge');
33
33
  const workspaceLayout = require('./workspace-layout');
34
+ const maintenance = require('./maintenance');
34
35
  const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
35
36
  // spec 判据与候选枚举的唯一入口(tier 分级 + 排序)
36
37
  const specDetect = require('../skywalk-sdd/ontology/spec-detect.cjs');
@@ -336,6 +337,7 @@ function copyDirRendered(source, target, config, toolKey) {
336
337
  return deployDir(source, target, {
337
338
  strategy: STRATEGY.OVERWRITE,
338
339
  render: (srcPath, tgtPath) => {
340
+ if (path.basename(srcPath) === 'config.json' && fs.existsSync(tgtPath)) return fs.readFileSync(tgtPath, 'utf8');
339
341
  const rendered = renderTemplate(fs.readFileSync(srcPath, 'utf8'), config, toolKey, profile);
340
342
  validateRenderedContent(rendered, profile, tgtPath);
341
343
  return rendered;
@@ -1077,6 +1079,9 @@ function attachCodeRepoLite(codeRepoRoot, specRepoRoot, options = {}) {
1077
1079
  return { ok: false, repo: label, message: `spec 路径不是 Git 仓库: ${specRoot}` };
1078
1080
  }
1079
1081
 
1082
+ const conflict = maintenance.associationConflict(cwd, specRoot);
1083
+ if (conflict) return { ok: false, repo: label, message: conflict };
1084
+
1080
1085
  console.log(`🔗 轻量接入代码仓(本地关联 Hook): ${label}`);
1081
1086
 
1082
1087
  const remote = sddConfig.gitRemoteUrl(specRoot) || 'git@gitlab.example.com:biz/xxx-sdd-specs.git';
@@ -1277,6 +1282,9 @@ async function populateSpecPackage(specRoot, options = {}) {
1277
1282
  copyTemplatesToProject(root);
1278
1283
  initGlobalOverview(root);
1279
1284
  deploySddGuideManual(root);
1285
+ // Spec 包可能是独立 Git 仓或单仓内的子目录;忽略规则必须落在它自身,
1286
+ // 不能只写工作区根,否则升级会把整套可再生成运行时显示为业务改动。
1287
+ updateGitignore(root, { specPackage: true });
1280
1288
  console.log(`✅ SDD 包裹包已就绪: ${path.basename(root)}`);
1281
1289
  return { ok: true, path: root };
1282
1290
  }
@@ -1347,13 +1355,20 @@ function removeOuterSkywalk(anchorDir) {
1347
1355
  return { ok: true, removed: true, mode: 'symlink' };
1348
1356
  }
1349
1357
  if (st.isDirectory()) {
1350
- fs.rmSync(outer, { recursive: true, force: true });
1351
- console.log(' ✓ 已移除 Git/工作区根多余的 skywalk-sdd/(实体在包裹包)');
1352
- return { ok: true, removed: true, mode: 'directory' };
1358
+ // 旧目录可能存有 KB 身份、待上报事件和用户文件,不能整目录删除。
1359
+ for (const file of maintenance.runtimeToolFiles(outer)) fs.unlinkSync(file);
1360
+ const prune = dir => {
1361
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1362
+ if (entry.isDirectory() && !['events', 'state'].includes(entry.name)) prune(path.join(dir, entry.name));
1363
+ }
1364
+ if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
1365
+ };
1366
+ prune(outer);
1367
+ const retained = fs.existsSync(outer);
1368
+ console.log(retained ? ' ℹ️ 旧 skywalk-sdd/ 的配置、历史数据和未知文件已保留;仅清理工具脚本' : ' ✓ 已清理根目录旧工具脚本');
1369
+ return { ok: true, removed: !retained, retained, mode: 'directory' };
1353
1370
  }
1354
- fs.unlinkSync(outer);
1355
- console.log(' ✓ 已移除 Git/工作区根多余的 skywalk-sdd');
1356
- return { ok: true, removed: true, mode: 'file' };
1371
+ return { ok: true, removed: false, skipped: true }; // 同名用户文件不处理
1357
1372
  } catch (err) {
1358
1373
  console.log(` ⚠️ 未能移除根目录 skywalk-sdd/: ${err.message}`);
1359
1374
  return { ok: false, message: err.message };
@@ -1364,7 +1379,7 @@ function removeOuterSkywalk(anchorDir) {
1364
1379
  * 创建 / 更新 .gitignore
1365
1380
  * @param {string} [cwd=process.cwd()]
1366
1381
  */
1367
- function updateGitignore(cwd = process.cwd()) {
1382
+ function updateGitignore(cwd = process.cwd(), options = {}) {
1368
1383
  const root = path.resolve(cwd);
1369
1384
  const gitignorePath = path.join(root, '.gitignore');
1370
1385
  const requiredLines = [
@@ -1383,6 +1398,10 @@ function updateGitignore(cwd = process.cwd()) {
1383
1398
  '**/skywalk-sdd/state/',
1384
1399
  '.worktrees/'
1385
1400
  ];
1401
+ const specPackageLines = options.specPackage ? maintenance.specPackageIgnoreLines() : [];
1402
+ if (options.specPackage) {
1403
+ requiredLines.push(...specPackageLines);
1404
+ }
1386
1405
 
1387
1406
  const sddConfig = `
1388
1407
  # KLD SDD AI 编辑器个人配置(请勿提交)
@@ -1406,6 +1425,10 @@ skywalk-sdd/state/
1406
1425
 
1407
1426
  # SDD Apply 隔离 worktree(本地临时目录)
1408
1427
  .worktrees/
1428
+ ${options.specPackage ? `
1429
+ # KLD SDD 安装器工具(可由 init/升级重新生成,不属于业务 Spec)
1430
+ ${specPackageLines.join('\n')}
1431
+ ` : ''}
1409
1432
  `;
1410
1433
 
1411
1434
  const result = deployFile(null, gitignorePath, {
@@ -1505,6 +1528,9 @@ function syncWorkspaceRepos(workspaceRoot = process.cwd(), options = {}) {
1505
1528
  console.log(`🆕 待接入新仓: ${filteredTargets.map((c) => c.name).join(', ') || '(无)'}`);
1506
1529
  }
1507
1530
 
1531
+ const conflicts = maintenance.maintenanceConflicts(root, specRootAbs, codeRepos);
1532
+ if (conflicts.length) return { ok: false, attached: [], failed: conflicts, layout, specRoot: specRootAbs, message: conflicts.map(c => c.repo + ': ' + c.message).join('; ') };
1533
+
1508
1534
  const attached = [];
1509
1535
  const failed = [];
1510
1536
  for (const repo of filteredTargets) {
@@ -1513,7 +1539,9 @@ function syncWorkspaceRepos(workspaceRoot = process.cwd(), options = {}) {
1513
1539
  else failed.push(result);
1514
1540
  }
1515
1541
 
1516
- // 刷新清单(spec 指向用户确认的那个;代码仓排除 spec 包自身)
1542
+ if (failed.length) return { ok: false, attached, failed, layout, specRoot: specRootAbs };
1543
+
1544
+ // 显式 sync-repos 成功后才刷新清单
1517
1545
  const layoutForFile = {
1518
1546
  ...layout,
1519
1547
  specRepo: { name: specName, abs: specRootAbs },
@@ -1614,6 +1642,12 @@ async function main(options = {}) {
1614
1642
  }
1615
1643
  }
1616
1644
 
1645
+ if (specPackage) {
1646
+ if (layout.isWorkspace) layout.codeRepos = maintenance.workspaceCodeRepos(workspaceRoot, specPackage.abs, layout, { respectManifest: true });
1647
+ const conflicts = maintenance.maintenanceConflicts(workspaceRoot, specPackage.abs, layout.isWorkspace ? layout.codeRepos : []);
1648
+ if (conflicts.length) throw new Error('未更新工具:' + conflicts.map(c => c.repo + ': ' + c.message).join('; '));
1649
+ }
1650
+
1617
1651
  if (layout.isWorkspace) {
1618
1652
  // 显示层同步过滤:用户交互选择的 spec 目录可能原被分类为代码仓
1619
1653
  const displayCodeRepos = specPackage
@@ -1650,18 +1684,18 @@ async function main(options = {}) {
1650
1684
  if (!layout.specRepo || path.resolve(layout.specRepo.abs) !== path.resolve(specPackage.abs)) {
1651
1685
  layout.specRepo = { name: specPackage.name, abs: specPackage.abs };
1652
1686
  }
1653
- layout.codeRepos = layout.codeRepos.filter(
1654
- (c) => path.resolve(c.abs) !== path.resolve(specPackage.abs),
1655
- );
1687
+ layout.codeRepos = maintenance.workspaceCodeRepos(workspaceRoot, specPackage.abs, layout, { respectManifest: true });
1656
1688
  }
1657
1689
 
1658
1690
  console.log();
1659
1691
  console.log('🔗 正在为子代码仓做轻量接入(不安装 skills)...');
1660
1692
  for (const repo of layout.codeRepos) {
1661
- attachCodeRepoLite(repo.abs, specPackage.abs);
1693
+ const result = attachCodeRepoLite(repo.abs, specPackage.abs);
1694
+ if (!result.ok) throw new Error(`${repo.name}: ${result.message}`);
1662
1695
  }
1663
- const wsFile = workspaceLayout.writeWorkspaceFile(workspaceRoot, layout);
1664
- console.log(`✅ 工作区清单: ${wsFile}`);
1696
+ const savedWorkspace = workspaceLayout.loadWorkspaceFile(workspaceRoot);
1697
+ const wsFile = savedWorkspace.ok ? savedWorkspace.path : workspaceLayout.writeWorkspaceFile(workspaceRoot, layout);
1698
+ console.log(`✅ 工作区清单${savedWorkspace.ok ? '已保留(新增仓用 sync-repos 接入)' : ''}: ${wsFile}`);
1665
1699
  } else {
1666
1700
  // 单仓:Git 根只放代码 + skills + Hook;SDD 内容进统一包裹子目录
1667
1701
  const isGit = workspaceLayout.isGitRepo(workspaceRoot);
@@ -1684,7 +1718,7 @@ async function main(options = {}) {
1684
1718
  if (linked.ok) {
1685
1719
  console.log(` ✓ sdd.specPath = ${linked.path}`);
1686
1720
  } else {
1687
- console.log(` ⚠️ 未能写入 sdd.specPath: ${linked.message}`);
1721
+ throw new Error(`未能写入 sdd.specPath: ${linked.message}`);
1688
1722
  }
1689
1723
  } else {
1690
1724
  // 非 Git 目录:仍创建包裹包,便于随后 git init
@@ -1713,7 +1747,7 @@ async function main(options = {}) {
1713
1747
  // 7. 更新工作目录 .gitignore
1714
1748
  updateGitignore(workspaceRoot);
1715
1749
 
1716
- // 8. 收尾:无论前面是否走过 symlink/旧副本路径,强制清掉工作区/Git 根多余 skywalk-sdd/
1750
+ // 8. 收尾:清理可识别的旧工具,保留历史配置和数据
1717
1751
  if (specPackage && fs.existsSync(specPackage.abs)) {
1718
1752
  writeSddSpecRootHint(workspaceRoot, specPackage.abs);
1719
1753
  }
@@ -1845,4 +1879,5 @@ module.exports = {
1845
1879
  promptSpecPath,
1846
1880
  listSpecCandidatesForInit,
1847
1881
  deployCodeRepoHooks,
1882
+ updateGitignore,
1848
1883
  };