kld-sdd 2.7.8-4 → 2.7.8-5

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';
@@ -136,19 +136,19 @@ function parseInitArgs(argv = cliArgs(), env = process.env) {
136
136
  * kld-sdd uninstall [选项]
137
137
  *
138
138
  * 卸载产物与编辑器无关(8 个编辑器的产物全量扫描),工作目录固定为当前目录,
139
- * 故不提供 --tool / --project:卸载范围由「方式选择」决定,目录由 cwd 决定。
139
+ * 故不提供 --tool / --project:仅卸载当前范围的工具,目录由 cwd 决定。
140
140
  *
141
141
  * --spec-path 用于无人值守场景:多候选(如用户复制的备份)时无法自动判定,
142
142
  * 需显式指定要卸载的 spec 仓库。
143
143
  */
144
144
  function parseUninstallArgs(argv = cliArgs()) {
145
- const purge = argv.includes('--purge');
145
+ if (argv.some(arg => arg === '--purge' || arg.startsWith('--purge=') || arg === '--full' || arg.startsWith('--mode'))) {
146
+ throw new Error('已取消全量删除,仅支持卸载工具;Spec 文档、配置与历史数据始终保留');
147
+ }
146
148
  return {
147
149
  cwd: process.cwd(),
148
150
  specPath: parseSpecPathOption(argv),
149
- mode: purge ? MODE.FULL : MODE.TOOLS_ONLY,
150
- // 是否显式指定了方式:未指定且未跳过交互时,进入渐进式方式选择
151
- modeExplicit: purge,
151
+ mode: MODE.TOOLS_ONLY,
152
152
  dryRun: argv.includes('--dry-run'),
153
153
  yes: hasFlag(argv, '--yes', '-y'),
154
154
  help: hasFlag(argv, '-h', '--help'),
@@ -325,78 +325,26 @@ async function selectSpecRoot(prompt, candidates, options = {}) {
325
325
  return selectSpecChoice(prompt, candidates, options);
326
326
  }
327
327
 
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
328
  /** 显示卸载帮助信息 */
359
329
  function showUninstallHelp() {
360
330
  console.log(`
361
- KLD SDD 卸载工具
331
+ KLD SDD 工具卸载
362
332
 
363
333
  用法:
364
- kld-sdd uninstall # 渐进式交互(推荐):选方式预览计划 → 确认
365
- kld-sdd uninstall [选项] # 非交互:显式给出方式与确认
334
+ kld-sdd uninstall # 选择 Spec 预览范围 → 确认
335
+ kld-sdd uninstall --dry-run # 只看计划,不修改文件
336
+ kld-sdd uninstall --yes --spec-path <dir> # 明确范围后用于脚本
366
337
 
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)。执行前会打印待删目录与文件数,需二次确认。
338
+ 仅删除可识别的 SDD Skills、运行脚本、只读模板与受管 Hook。
339
+ 始终保留 openspec/ 全部文档、KB 配置、项目身份、历史数据、多仓关联和 Git 历史。
340
+ 自定义 Hook、规则、未知文件与隐私忽略规则保留。不提供全量删除模式。
394
341
 
395
- 示例:
396
- kld-sdd uninstall # 交互式,最常用(在项目目录下执行)
397
- kld-sdd uninstall --dry-run # 只看计划
398
- kld-sdd uninstall --purge --dry-run # 全量预演
399
- kld-sdd uninstall --purge --yes # 全量执行,不询问
342
+ -h, --help 显示本帮助
343
+ --spec-path <dir> 指定目标 Spec;多个候选时必须选择,备份不受影响
344
+ --dry-run 只预览
345
+ -y, --yes 确认工具卸载;非交互执行必须提供
346
+
347
+ 升级不需要先卸载:重新运行 init 覆盖工具文件,保留已有文档及配置。
400
348
  `);
401
349
  }
402
350
 
@@ -409,8 +357,8 @@ KLD SDD 项目初始化工具
409
357
  kld-sdd-init [选项]
410
358
  npx kld-sdd [选项]
411
359
  kld-sdd sync-repos # 工作目录下发现新增代码仓并轻量接入(多 spec 候选时单选)
412
- kld-sdd uninstall [选项] # 卸载 SDD 产物(渐进式:选方式预览 → 确认)
413
- kld-sdd uninstall --help # 查看卸载选项与两种方式的区别
360
+ kld-sdd uninstall [选项] # 卸载 SDD 产物(渐进式:预览工具范围 → 确认)
361
+ kld-sdd uninstall --help # 查看工具卸载与保留范围
414
362
 
415
363
  选项:
416
364
  -h, --help 显示帮助信息
@@ -484,7 +432,7 @@ async function runInit() {
484
432
  /**
485
433
  * 卸载(渐进式):方式选择 → 计划预览 → 确认执行。
486
434
  *
487
- * 交互编排全部在本函数(CLI 输入层);lib/uninstall 只提供纯计算与执行:
435
+ * 交互编排全部在本函数(CLI 输入层);仅选择 Spec、预览工具范围并确认:
488
436
  * - buildUninstallPlan 纯计算计划,不落盘
489
437
  * - main 按给定计划执行,无交互
490
438
  *
@@ -524,7 +472,7 @@ async function runUninstall(argv = args, deps = {}) {
524
472
  // 候选枚举:结构判据无法区分「当前项目的 spec 仓」与「用户复制的备份」
525
473
  //(如 `erp-sdd-specs copy` 结构标记齐全),故由用户单选裁决。
526
474
  // 显式 --spec-path 时跳过枚举(调用方已指定)。
527
- let specRoot = opts.specPath ? path.resolve(opts.specPath) : undefined;
475
+ let specRoot = opts.specPath ? path.resolve(cwd, opts.specPath) : undefined;
528
476
  const candidates = specRoot ? [] : listSpecCandidatesForUninstall(cwd);
529
477
 
530
478
  if (!specRoot && candidates.length > 1 && !canPrompt) {
@@ -539,6 +487,7 @@ async function runUninstall(argv = args, deps = {}) {
539
487
  throw new Error('多个 spec 仓库候选,需 --spec-path 显式指定');
540
488
  }
541
489
 
490
+ if (!specRoot && !canPrompt && candidates.length === 1) specRoot = candidates[0].abs;
542
491
  const prompt = canPrompt ? promptFactory() : null;
543
492
 
544
493
  // 步骤 1:spec 仓库选择(多候选 → 用户单选;单候选 → 确认)
@@ -563,31 +512,15 @@ async function runUninstall(argv = args, deps = {}) {
563
512
  }
564
513
 
565
514
  if (!prompt) {
515
+ if (!dryRun && !opts.yes) throw new Error('非交互卸载需要 --yes;可先用 --dry-run 预览');
566
516
  printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
567
517
  if (dryRun) printPlanPreview(planned.plan);
568
518
  // 已打印过预览(含警告)时不重复;直接执行时由 lib 打印
569
- runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: !dryRun });
519
+ const result = runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: !dryRun });
520
+ if (!result.ok) throw new Error(result.errors.join('; '));
570
521
  return;
571
522
  }
572
523
 
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
524
  // 步骤 4:计划预览
592
525
  printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
593
526
  printPlanPreview(planned.plan);
@@ -602,7 +535,8 @@ async function runUninstall(argv = args, deps = {}) {
602
535
  console.log('');
603
536
 
604
537
  // 交互路径:警告已由 printPlanPreview 打印过,不再重复
605
- runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: false });
538
+ const result = runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: false });
539
+ if (!result.ok) throw new Error(result.errors.join('; '));
606
540
  } finally {
607
541
  if (prompt) prompt.close();
608
542
  }
@@ -724,7 +658,6 @@ module.exports = {
724
658
  runUninstall,
725
659
  parseUninstallArgs,
726
660
  printPlanPreview,
727
- selectUninstallMode,
728
661
  selectSpecRoot,
729
662
  showUninstallHelp,
730
663
  };
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';
@@ -1347,13 +1352,20 @@ function removeOuterSkywalk(anchorDir) {
1347
1352
  return { ok: true, removed: true, mode: 'symlink' };
1348
1353
  }
1349
1354
  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' };
1355
+ // 旧目录可能存有 KB 身份、待上报事件和用户文件,不能整目录删除。
1356
+ for (const file of maintenance.runtimeToolFiles(outer)) fs.unlinkSync(file);
1357
+ const prune = dir => {
1358
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1359
+ if (entry.isDirectory() && !['events', 'state'].includes(entry.name)) prune(path.join(dir, entry.name));
1360
+ }
1361
+ if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
1362
+ };
1363
+ prune(outer);
1364
+ const retained = fs.existsSync(outer);
1365
+ console.log(retained ? ' ℹ️ 旧 skywalk-sdd/ 的配置、历史数据和未知文件已保留;仅清理工具脚本' : ' ✓ 已清理根目录旧工具脚本');
1366
+ return { ok: true, removed: !retained, retained, mode: 'directory' };
1353
1367
  }
1354
- fs.unlinkSync(outer);
1355
- console.log(' ✓ 已移除 Git/工作区根多余的 skywalk-sdd');
1356
- return { ok: true, removed: true, mode: 'file' };
1368
+ return { ok: true, removed: false, skipped: true }; // 同名用户文件不处理
1357
1369
  } catch (err) {
1358
1370
  console.log(` ⚠️ 未能移除根目录 skywalk-sdd/: ${err.message}`);
1359
1371
  return { ok: false, message: err.message };
@@ -1505,6 +1517,9 @@ function syncWorkspaceRepos(workspaceRoot = process.cwd(), options = {}) {
1505
1517
  console.log(`🆕 待接入新仓: ${filteredTargets.map((c) => c.name).join(', ') || '(无)'}`);
1506
1518
  }
1507
1519
 
1520
+ const conflicts = maintenance.maintenanceConflicts(root, specRootAbs, codeRepos);
1521
+ if (conflicts.length) return { ok: false, attached: [], failed: conflicts, layout, specRoot: specRootAbs, message: conflicts.map(c => c.repo + ': ' + c.message).join('; ') };
1522
+
1508
1523
  const attached = [];
1509
1524
  const failed = [];
1510
1525
  for (const repo of filteredTargets) {
@@ -1513,7 +1528,9 @@ function syncWorkspaceRepos(workspaceRoot = process.cwd(), options = {}) {
1513
1528
  else failed.push(result);
1514
1529
  }
1515
1530
 
1516
- // 刷新清单(spec 指向用户确认的那个;代码仓排除 spec 包自身)
1531
+ if (failed.length) return { ok: false, attached, failed, layout, specRoot: specRootAbs };
1532
+
1533
+ // 显式 sync-repos 成功后才刷新清单
1517
1534
  const layoutForFile = {
1518
1535
  ...layout,
1519
1536
  specRepo: { name: specName, abs: specRootAbs },
@@ -1614,6 +1631,12 @@ async function main(options = {}) {
1614
1631
  }
1615
1632
  }
1616
1633
 
1634
+ if (specPackage) {
1635
+ if (layout.isWorkspace) layout.codeRepos = maintenance.workspaceCodeRepos(workspaceRoot, specPackage.abs, layout, { respectManifest: true });
1636
+ const conflicts = maintenance.maintenanceConflicts(workspaceRoot, specPackage.abs, layout.isWorkspace ? layout.codeRepos : []);
1637
+ if (conflicts.length) throw new Error('未更新工具:' + conflicts.map(c => c.repo + ': ' + c.message).join('; '));
1638
+ }
1639
+
1617
1640
  if (layout.isWorkspace) {
1618
1641
  // 显示层同步过滤:用户交互选择的 spec 目录可能原被分类为代码仓
1619
1642
  const displayCodeRepos = specPackage
@@ -1650,18 +1673,18 @@ async function main(options = {}) {
1650
1673
  if (!layout.specRepo || path.resolve(layout.specRepo.abs) !== path.resolve(specPackage.abs)) {
1651
1674
  layout.specRepo = { name: specPackage.name, abs: specPackage.abs };
1652
1675
  }
1653
- layout.codeRepos = layout.codeRepos.filter(
1654
- (c) => path.resolve(c.abs) !== path.resolve(specPackage.abs),
1655
- );
1676
+ layout.codeRepos = maintenance.workspaceCodeRepos(workspaceRoot, specPackage.abs, layout, { respectManifest: true });
1656
1677
  }
1657
1678
 
1658
1679
  console.log();
1659
1680
  console.log('🔗 正在为子代码仓做轻量接入(不安装 skills)...');
1660
1681
  for (const repo of layout.codeRepos) {
1661
- attachCodeRepoLite(repo.abs, specPackage.abs);
1682
+ const result = attachCodeRepoLite(repo.abs, specPackage.abs);
1683
+ if (!result.ok) throw new Error(`${repo.name}: ${result.message}`);
1662
1684
  }
1663
- const wsFile = workspaceLayout.writeWorkspaceFile(workspaceRoot, layout);
1664
- console.log(`✅ 工作区清单: ${wsFile}`);
1685
+ const savedWorkspace = workspaceLayout.loadWorkspaceFile(workspaceRoot);
1686
+ const wsFile = savedWorkspace.ok ? savedWorkspace.path : workspaceLayout.writeWorkspaceFile(workspaceRoot, layout);
1687
+ console.log(`✅ 工作区清单${savedWorkspace.ok ? '已保留(新增仓用 sync-repos 接入)' : ''}: ${wsFile}`);
1665
1688
  } else {
1666
1689
  // 单仓:Git 根只放代码 + skills + Hook;SDD 内容进统一包裹子目录
1667
1690
  const isGit = workspaceLayout.isGitRepo(workspaceRoot);
@@ -1684,7 +1707,7 @@ async function main(options = {}) {
1684
1707
  if (linked.ok) {
1685
1708
  console.log(` ✓ sdd.specPath = ${linked.path}`);
1686
1709
  } else {
1687
- console.log(` ⚠️ 未能写入 sdd.specPath: ${linked.message}`);
1710
+ throw new Error(`未能写入 sdd.specPath: ${linked.message}`);
1688
1711
  }
1689
1712
  } else {
1690
1713
  // 非 Git 目录:仍创建包裹包,便于随后 git init
@@ -1713,7 +1736,7 @@ async function main(options = {}) {
1713
1736
  // 7. 更新工作目录 .gitignore
1714
1737
  updateGitignore(workspaceRoot);
1715
1738
 
1716
- // 8. 收尾:无论前面是否走过 symlink/旧副本路径,强制清掉工作区/Git 根多余 skywalk-sdd/
1739
+ // 8. 收尾:清理可识别的旧工具,保留历史配置和数据
1717
1740
  if (specPackage && fs.existsSync(specPackage.abs)) {
1718
1741
  writeSddSpecRootHint(workspaceRoot, specPackage.abs);
1719
1742
  }
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ // 安装、升级、卸载共用的边界;业务文档和关联配置不属于可清理的工具文件。
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const sdd = require('../skywalk-sdd/ontology/sdd-config.cjs');
7
+ const layout = require('./workspace-layout');
8
+ const detect = require('../skywalk-sdd/ontology/spec-detect.cjs');
9
+ const pkg = path.resolve(__dirname, '..');
10
+
11
+ function filesUnder(root, prefix = '') {
12
+ if (!fs.existsSync(root) || !fs.lstatSync(root).isDirectory()) return [];
13
+ return fs.readdirSync(root).flatMap(name => {
14
+ const file = path.join(root, name);
15
+ const rel = path.join(prefix, name);
16
+ const stat = fs.lstatSync(file);
17
+ return stat.isDirectory() ? filesUnder(file, rel) : stat.isFile() ? [rel] : [];
18
+ });
19
+ }
20
+
21
+ function regularFileWithin(root, relative) {
22
+ const parts = relative.split(path.sep);
23
+ if (path.isAbsolute(relative) || parts.includes('..')) return false;
24
+ let target = root;
25
+ if (!fs.existsSync(root) || !fs.lstatSync(root).isDirectory()) return false;
26
+ for (let i = 0; i < parts.length; i++) {
27
+ target = path.join(target, parts[i]);
28
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
29
+ if (!stat || stat.isSymbolicLink() || (i < parts.length - 1 && !stat.isDirectory())) return false;
30
+ if (i === parts.length - 1) return stat.isFile();
31
+ }
32
+ return false;
33
+ }
34
+
35
+ function runtimeToolFiles(root) {
36
+ const names = filesUnder(path.join(pkg, 'skywalk-sdd'))
37
+ .filter(rel => /\.(cjs|js|json)$/.test(rel) && !/^(events|state)[/\\]/.test(rel) && path.basename(rel) !== 'project-identity.json')
38
+ .map(rel => rel === 'index.cjs' ? 'log.cjs' : rel);
39
+ names.push(path.join('lib', 'scale-thresholds.json'));
40
+ for (const name of filesUnder(path.join(pkg, 'templates/git-hooks'))) {
41
+ if (name.endsWith('.cjs')) names.push(path.join('git-hooks', name));
42
+ }
43
+ return [...new Set(names)].filter(rel => regularFileWithin(root, rel)).map(rel => path.join(root, rel));
44
+ }
45
+
46
+ function samePath(a, b) {
47
+ const canonical = value => fs.existsSync(value) ? fs.realpathSync(value) : path.resolve(value);
48
+ return canonical(a) === canonical(b);
49
+ }
50
+
51
+ // 返回冲突原因,不自动迁移已有关系;明确换仓应通过 link-spec / 配置处理。
52
+ function associationConflict(repo, spec) {
53
+ const bindings = [sdd.gitConfig(repo, 'sdd.specPath')];
54
+ const hint = path.join(repo, '.sdd-spec-root');
55
+ if (fs.existsSync(hint)) bindings.push(fs.readFileSync(hint, 'utf8').trim());
56
+ if (bindings.some(value => value && !samePath(path.resolve(repo, value), spec))) {
57
+ return '已有本地 Spec 关联指向其他目录,请先核对关联';
58
+ }
59
+ const yaml = sdd.loadSddYaml(repo);
60
+ if (fs.existsSync(yaml.path) && !yaml.ok) return '已有 .sdd.yaml 无法解析,请先修复配置';
61
+ if (yaml.ok && yaml.layout === 'mono') {
62
+ if (yaml.spec_path ? !samePath(path.resolve(repo, yaml.spec_path), spec) : !bindings.some(value => value && samePath(path.resolve(repo, value), spec))) return '此仓使用独立的单仓 Spec 配置';
63
+ }
64
+ if (yaml.ok && yaml.spec_repository && !yaml.spec_repository.includes('gitlab.example.com:biz/xxx-sdd-specs.git')) {
65
+ const remote = sdd.gitRemoteUrl(spec);
66
+ if (!remote || !sdd.remotesMatch(yaml.spec_repository, remote)) return '.sdd.yaml 的 Spec 远程仓库与所选目录不一致';
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function workspaceCodeRepos(root, spec, current = layout.detectWorkspaceLayout(root), options = {}) {
72
+ const excluded = new Set(detect.detectSpecCandidatesWithGit(root).map(c => path.resolve(c.abs)));
73
+ excluded.add(path.resolve(spec));
74
+ const candidates = current.codeRepos.filter(repo => !excluded.has(path.resolve(repo.abs)));
75
+ const saved = options.respectManifest && layout.loadWorkspaceFile(root);
76
+ if (!saved || !saved.ok) return candidates;
77
+ const declared = saved.data.code_repos.map(value => path.resolve(root, value.replace(/^(["'])(.*)\1$/, '$2')));
78
+ const missing = declared.filter(dir => !candidates.some(repo => samePath(repo.abs, dir)));
79
+ if (missing.length) throw new Error('多仓清单包含未发现或非代码仓目录,请先核对 .sdd-workspace.yaml:' + missing.map(p => path.basename(p)).join('、'));
80
+ return candidates.filter(repo => declared.some(dir => samePath(dir, repo.abs)));
81
+ }
82
+
83
+ function maintenanceConflicts(root, spec, repos) {
84
+ const failures = [];
85
+ const saved = layout.loadWorkspaceFile(root);
86
+ if (saved.ok && saved.data.spec_path && !samePath(path.resolve(root, saved.data.spec_path), spec)) {
87
+ failures.push({ repo: path.basename(root), message: '.sdd-workspace.yaml 指向其他 Spec,升级不会重新绑定' });
88
+ }
89
+ for (const repo of [{ abs: root, name: path.basename(root) }, ...repos]) {
90
+ const message = associationConflict(repo.abs, spec);
91
+ if (message) failures.push({ repo: repo.name, message });
92
+ }
93
+ return failures;
94
+ }
95
+
96
+ module.exports = { filesUnder, regularFileWithin, runtimeToolFiles, samePath, associationConflict, workspaceCodeRepos, maintenanceConflicts };
package/lib/uninstall.js CHANGED
@@ -1,27 +1,4 @@
1
- /**
2
- * KLD SDD 卸载
3
- *
4
- * 与 init 共用同一套「产物归属」判定,方向相反:
5
- * - 工具产物(安装时 OVERWRITE)→ 删除
6
- * - 用户配置(安装时 SKIP_IF_EXISTS)→ 默认保留,仅 --purge 才删
7
- * - MERGE 类(settings.json)→ 只摘除 SDD 受管 hook,文件其余内容原样保留
8
- * - MARKER 类(.git/hooks)→ 只删含受管标记的,非受管 hook 保留
9
- * - APPEND_LINES(.gitignore)→ 只摘除 SDD 块,其余行保留
10
- * - 运行时数据(events/state)→ 默认保留(含用户历史度量),仅 --purge 才删
11
- * - 团队/项目配置(project-identity.json、git-hooks/hooks.config)→ 默认保留。
12
- * 二者都不是「工具可重置」的产物:前者是团队共享的 KB 身份(需提交 Git),
13
- * 后者是项目级门禁参数(init 用 SKIP_IF_EXISTS 保护),重跑 init 不得重置。
14
- *
15
- * 两种模式:
16
- * - MODE.TOOLS_ONLY(默认):只删工具产物,保留全部用户配置与数据
17
- * - MODE.FULL(--purge):连用户配置与运行时数据一并删除
18
- *
19
- * 安全约束:
20
- * - 任何删除前先经 scope 白名单校验(只允许操作 SDD 自有目录 / 受管标记文件)
21
- * - --dry-run 只打印计划,不落盘
22
- * - 删除文件后清理变空的目录,但不删除用户可能仍有内容的目录
23
- */
24
-
1
+ /** 只卸载可识别的 SDD 工具;文档、配置、数据和仓库关联始终保留。 */
25
2
  'use strict';
26
3
 
27
4
  const fs = require('fs');
@@ -29,7 +6,10 @@ const path = require('path');
29
6
 
30
7
  const { isManagedBridgeFile } = require('./command-bridge');
31
8
  const { PROVIDERS, stripManagedHooks } = require('./settings-merge');
32
- const { DEFAULT_HOOK_MARKER } = require('./deploy-strategy');
9
+ const crypto = require('crypto');
10
+ const { execFileSync } = require('child_process');
11
+ const maintenance = require('./maintenance');
12
+ const packageRoot = path.resolve(__dirname, '..');
33
13
  const { getToolProfile, listToolProfileIds, buildToolConfigs } = require('./tool-profiles');
34
14
  const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
35
15
  const workspaceLayout = require('./workspace-layout');
@@ -40,17 +20,12 @@ const specDetect = require('../skywalk-sdd/ontology/spec-detect.cjs');
40
20
  const MODE = Object.freeze({
41
21
  /** 只删工具内容,保留用户配置与运行时数据 */
42
22
  TOOLS_ONLY: 'tools-only',
43
- /** 全量删除,含用户配置与运行时数据 */
44
- FULL: 'full',
45
23
  });
46
24
 
47
25
  /** 操作类型(用于计划与摘要) */
48
26
  const OP = Object.freeze({
49
27
  DELETE_FILE: 'delete-file',
50
- DELETE_DIR: 'delete-dir',
51
28
  STRIP_HOOKS: 'strip-hooks',
52
- STRIP_GITIGNORE: 'strip-gitignore',
53
- UNLINK_GIT_CONFIG: 'unlink-git-config',
54
29
  KEEP: 'keep',
55
30
  });
56
31
 
@@ -90,263 +65,78 @@ function listFilesRecursive(dir) {
90
65
  return out;
91
66
  }
92
67
 
93
- /**
94
- * 规划:编辑器专属产物(skills / commands / hooks / rules)
95
- *
96
- * 策略:
97
- * - `skillsDir/opsx-*` 与 `openspec-sync-specs` 等 SDD skill 目录 → 整体删(工具产物)
98
- * - `opsxCommandsDir/*.md` 只删含 BRIDGE_MARKER
99
- * - `configDir/hooks/sdd-*.cjs` + `hook-gate-core.cjs` → 删(工具产物)
100
- * - `configDir/settings.json` → 摘除受管 hook(保留文件)
101
- */
68
+ /** 按随包工具文件清单逐文件清理,未知文件和用户添加的文件不删。 */
69
+ function planOwnedFiles(plan, source, target, reason) {
70
+ for (const rel of maintenance.filesUnder(source)) {
71
+ // Skill 的本地检索配置可能包含连接信息,不作为工具脚本清理。
72
+ if (path.basename(rel) === 'config.json') continue;
73
+ if (maintenance.regularFileWithin(target, rel)) addOp(plan, OP.DELETE_FILE, path.join(target, rel), reason);
74
+ }
75
+ }
76
+
102
77
  function planProfileArtifacts(plan, toolKey, cwd) {
103
78
  const profile = getToolProfile(toolKey);
104
79
  const config = buildToolConfigs()[toolKey];
105
80
  if (!config) return;
106
-
107
- // 1) skills:SDD 部署的 skill 目录(opsx-* + openspec-sync-specs/update-change + tdd-*)
108
81
  if (profile.skillsDir) {
109
- const skillsDir = path.join(cwd, profile.skillsDir);
110
- if (fs.existsSync(skillsDir)) {
111
- for (const name of fs.readdirSync(skillsDir)) {
112
- const target = path.join(skillsDir, name);
113
- if (!fs.statSync(target).isDirectory()) continue;
114
- if (!/^(opsx-|openspec-(sync-specs|update-change)$)|^tdd-/.test(name)) continue;
115
- addOp(plan, OP.DELETE_DIR, target, `${config.name} SDD skill 目录`);
116
- }
117
- }
82
+ planOwnedFiles(plan, path.join(packageRoot, 'templates/skills/kld-sdd'), path.join(cwd, profile.skillsDir), `${config.name} SDD skill 文件`);
118
83
  }
119
-
120
- // 2) Kunlun command bridge:只删含受管标记的
121
84
  if (profile.requiresCommandBridge && profile.opsxCommandsDir) {
122
- const opsxDir = path.join(cwd, profile.opsxCommandsDir);
123
- for (const file of listFilesRecursive(opsxDir)) {
124
- if (!file.endsWith('.md')) continue;
125
- const content = fs.readFileSync(file, 'utf8');
126
- if (isManagedBridgeFile(content)) {
127
- addOp(plan, OP.DELETE_FILE, file, 'SDD command bridge(受管标记)');
128
- } else {
129
- addOp(plan, OP.KEEP, file, '非 SDD 受管命令,保留');
130
- }
85
+ const root = path.join(cwd, profile.opsxCommandsDir);
86
+ for (const rel of maintenance.filesUnder(root)) {
87
+ if (!rel.endsWith('.md') || !maintenance.regularFileWithin(cwd, path.relative(cwd, path.join(root, rel)))) continue;
88
+ const file = path.join(root, rel);
89
+ if (isManagedBridgeFile(fs.readFileSync(file, 'utf8'))) addOp(plan, OP.DELETE_FILE, file, 'SDD command bridge');
131
90
  }
132
91
  }
133
-
134
- // 3) Hook 脚本目录(claude / codebuddy)
135
92
  if (profile.hookProvider && profile.hookProvider !== 'none') {
136
- const hooksDir = path.join(cwd, profile.configDir, 'hooks');
137
- for (const file of listFilesRecursive(hooksDir)) {
138
- const base = path.basename(file);
139
- if (/^sdd-[\w-]+\.cjs$/.test(base) || base === 'hook-gate-core.cjs') {
140
- addOp(plan, OP.DELETE_FILE, file, `${config.name} SDD hook 脚本`);
141
- }
142
- }
143
-
144
- // 4) settings.json:摘除受管 hook
145
- const settingsPath = path.join(cwd, profile.configDir, 'settings.json');
146
- if (fs.existsSync(settingsPath)) {
147
- addOp(plan, OP.STRIP_HOOKS, settingsPath, `${config.name} settings.json 摘除 SDD 受管 hook`);
148
- }
149
- }
150
-
151
- // 5) rules 目录(cursor .cursor/rules 等)中的 SDD 规则
152
- if (profile.rulesDir) {
153
- const rulesDir = path.join(cwd, profile.rulesDir);
154
- for (const file of listFilesRecursive(rulesDir)) {
155
- const base = path.basename(file);
156
- if (/^(sdd|opsx)[\w.-]*\.(md|mdc)$/i.test(base)) {
157
- addOp(plan, OP.DELETE_FILE, file, `${config.name} SDD rules`);
158
- }
159
- }
160
- }
161
- }
162
-
163
- /**
164
- * 规划:spec 仓 `skywalk-sdd/git-hooks/` 目录(混合归属)
165
- *
166
- * init 侧 `ensureSpecGitHooks` 对同一目录用了两套策略:
167
- * - `*.cjs` 工具产物 → OVERWRITE
168
- * - `hooks.config` 用户配置 → SKIP_IF_EXISTS
169
- *
170
- * 卸载侧必须按同一判据逐项处理,否则「装进去的删不掉」或
171
- * 「用户配置被静默重置」二者必居其一。
172
- */
173
- function planSpecGitHooks(plan, hooksDir) {
174
- for (const name of fs.readdirSync(hooksDir)) {
175
- const target = path.join(hooksDir, name);
176
- let st;
177
- try {
178
- st = fs.statSync(target);
179
- } catch {
180
- continue;
181
- }
182
- if (st.isDirectory()) {
183
- addOp(plan, OP.DELETE_DIR, target, 'SDD 运行时(工具内容)');
184
- continue;
185
- }
186
- if (name === 'hooks.config') {
187
- addOp(plan, OP.KEEP, target, '项目级门禁配置(init 用 SKIP_IF_EXISTS 保护),保留');
188
- continue;
189
- }
190
- addOp(plan, OP.DELETE_FILE, target, 'SDD Git hook 脚本(工具产物)');
93
+ planOwnedFiles(plan, path.join(packageRoot, 'templates/hooks', profile.hookProvider, 'hooks'), path.join(cwd, profile.configDir, 'hooks'), `${config.name} SDD hook`);
94
+ const relative = path.join(profile.configDir, 'settings.json');
95
+ if (maintenance.regularFileWithin(cwd, relative)) addOp(plan, OP.STRIP_HOOKS, path.join(cwd, relative), '仅摘除 SDD 受管 hook');
191
96
  }
97
+ // rules 由用户或 Agent 编写,不因名称带 sdd/opsx 就删除。
192
98
  }
193
99
 
194
- /**
195
- * 规划:spec 仓内容
196
- *
197
- * 工具产物:skywalk-sdd/(除下方保留项)、openspec-templates/、操作手册
198
- * 用户配置:modules.yaml、sdd.config.yaml、kb-state.json(仅 FULL 删)
199
- * 团队/项目配置:project-identity.json、git-hooks/hooks.config(两种模式都保留)
200
- * 文档工作区:openspec/(仅 FULL 整目录删)
201
- *
202
- * 注意:spec 仓自身的 `.git/hooks`(init 用 deployQualityGateTemplates 装的
203
- * commit-msg / pre-commit / pre-push)由 buildUninstallPlan 调用 planCodeRepo
204
- * 统一处理,保证「装得进去的一定摘得掉」,且与代码仓用同一套受管标记判据。
205
- */
206
- function planSpecPackage(plan, specRoot, options) {
100
+ function planSpecPackage(plan, specRoot) {
207
101
  if (!specRoot || !fs.existsSync(specRoot)) return;
208
-
209
- const skywalkDir = path.join(specRoot, 'skywalk-sdd');
210
- if (fs.existsSync(skywalkDir)) {
211
- if (options.mode === MODE.FULL) {
212
- // 透明性:project-identity.json 是团队共享的 KB 身份(已提交 spec 仓 Git),
213
- // 全量卸载会一并清除,需重跑 /opsx-kb-config 并重新提交。先单独列出,
214
- // 让用户在预览里明确看到「团队配置将被清除」——比静默随目录消失可审计。
215
- // 顺序必须在 DELETE_DIR 之前,否则父目录先被删、该项会被跳过。
216
- const identity = path.join(skywalkDir, 'project-identity.json');
217
- if (fs.existsSync(identity)) {
218
- addOp(plan, OP.DELETE_FILE, identity, 'KB 项目身份(团队共享,需重跑 /opsx-kb-config 并重新提交)');
219
- }
220
- addOp(plan, OP.DELETE_DIR, skywalkDir, 'SDD 运行时(含 events/state 数据)');
221
- } else {
222
- // 工具内容删除:删工具脚本,保留运行时数据与用户/团队配置。
223
- //
224
- // 保留项判据是「重跑 init 能不能恢复」:
225
- // - events/、state/ 运行时数据(用户历史度量),init 不重建内容
226
- // - project-identity.json 团队共享的 KB 身份,由 /opsx-kb-config 写、
227
- // 需提交 spec 仓 Git,删掉等于毁掉团队配置
228
- // - git-hooks/hooks.config 项目级门禁参数(business_code_exts/strict/
229
- // warn_only),init 用 SKIP_IF_EXISTS 保护,
230
- // 随目录一起删会让重跑 init 静默重置用户设置
231
- const keepItems = new Map([
232
- ['events', '运行时数据(度量),保留'],
233
- ['state', '运行时数据(状态),保留'],
234
- ['project-identity.json', 'KB 项目身份(团队共享,保留)'],
235
- ]);
236
- for (const name of fs.readdirSync(skywalkDir)) {
237
- const target = path.join(skywalkDir, name);
238
- if (keepItems.has(name)) {
239
- addOp(plan, OP.KEEP, target, keepItems.get(name));
240
- continue;
241
- }
242
- // git-hooks/ 是混合目录:*.cjs 是工具产物(OVERWRITE),
243
- // hooks.config 是用户配置(SKIP_IF_EXISTS)→ 逐项判定,不整目录删。
244
- if (name === 'git-hooks' && fs.statSync(target).isDirectory()) {
245
- planSpecGitHooks(plan, target);
246
- continue;
247
- }
248
- const st = fs.statSync(target);
249
- addOp(plan, st.isDirectory() ? OP.DELETE_DIR : OP.DELETE_FILE, target, 'SDD 运行时(工具内容)');
250
- }
251
- }
252
- }
253
-
254
- const templatesDir = path.join(specRoot, 'openspec-templates');
255
- if (fs.existsSync(templatesDir)) {
256
- addOp(plan, OP.DELETE_DIR, templatesDir, '只读文档模板');
257
- }
258
-
259
- const manual = path.join(specRoot, 'openspec', 'kld-sdd操作手册.html');
260
- if (fs.existsSync(manual)) {
261
- addOp(plan, OP.DELETE_FILE, manual, 'SDD 操作手册');
102
+ for (const file of maintenance.runtimeToolFiles(path.join(specRoot, 'skywalk-sdd'))) {
103
+ addOp(plan, OP.DELETE_FILE, file, 'SDD 运行工具');
262
104
  }
263
-
264
- // 用户产物
265
- for (const name of ['modules.yaml', 'sdd.config.yaml']) {
105
+ planOwnedFiles(plan, path.join(packageRoot, 'templates/openspec'), path.join(specRoot, 'openspec-templates'), 'SDD 只读模板');
106
+ for (const name of ['openspec', 'modules.yaml', 'sdd.config.yaml', 'kb-state.json', 'skywalk-sdd/events', 'skywalk-sdd/state', 'skywalk-sdd/project-identity.json', 'skywalk-sdd/git-hooks/hooks.config']) {
266
107
  const target = path.join(specRoot, name);
267
- if (!fs.existsSync(target)) continue;
268
- if (options.mode === MODE.FULL) {
269
- addOp(plan, OP.DELETE_FILE, target, 'SDD 用户配置');
270
- } else {
271
- addOp(plan, OP.KEEP, target, '用户配置,保留');
272
- }
273
- }
274
-
275
- // KB 绑定状态(含 API Key 与 spaceId/kbId,由 opsx-kb-config 写入 spec 仓根)。
276
- // 与 skywalk-sdd/project-identity.json 同属 KB 配置,全量模式下须一并清除,
277
- // 否则会留下「身份文件已删、绑定状态还在」的不一致中间态。
278
- const kbState = path.join(specRoot, 'kb-state.json');
279
- if (fs.existsSync(kbState)) {
280
- if (options.mode === MODE.FULL) {
281
- addOp(plan, OP.DELETE_FILE, kbState, 'KB 绑定状态(含 API Key)');
282
- } else {
283
- addOp(plan, OP.KEEP, kbState, 'KB 绑定状态(含 API Key),保留');
284
- }
285
- }
286
-
287
- // openspec/ 是 SDD 的文档工作区(changes/ 变更提案、specs/ 规格与全局契约 overview.md、
288
- // config.yaml),全部由 SDD 工具链创建与消费。
289
- // 工具内容模式:不纳入计划(用户配置与文档内容一律保留)。
290
- // 全量模式:整目录删除 —— 全量的语义就是「清空 SDD 在该仓的全部痕迹」,
291
- // 不对单个文件做内容判定,避免残留半套目录结构。
292
- if (options.mode === MODE.FULL) {
293
- const openspecDir = path.join(specRoot, 'openspec');
294
- if (fs.existsSync(openspecDir)) {
295
- addOp(plan, OP.DELETE_DIR, openspecDir, 'SDD 文档工作区(changes/specs/config.yaml)');
296
- warn(plan, `即将删除 SDD 文档工作区(含 changes/ 与 specs/ 内的业务文档): ${openspecDir}`);
297
- }
108
+ if (fs.existsSync(target)) addOp(plan, OP.KEEP, target, '项目文档、配置或历史数据');
298
109
  }
110
+ // 即使没有文档,也不删除 Spec 仓根目录及 .git。
111
+ addOp(plan, OP.KEEP, specRoot, 'Spec 仓库目录');
299
112
  }
300
113
 
301
- /**
302
- * 规划:代码仓 / 工作区根产物
303
- *
304
- * - `.sdd.yaml`、`.sdd-spec-root`、`.sdd-workspace.yaml` → 用户配置,仅 FULL 删
305
- * - `.git/hooks/{commit-msg,pre-commit,pre-push}` → 只删含受管标记的
306
- * - `.gitignore` → 摘除 SDD 块
307
- * - `git config --local sdd.specPath` → 删除
308
- */
309
- function planCodeRepo(plan, repoRoot, options) {
114
+ function planCodeRepo(plan, repoRoot) {
310
115
  if (!repoRoot || !fs.existsSync(repoRoot)) return;
311
-
312
- for (const name of ['.sdd.yaml', '.sdd-spec-root', '.sdd-workspace.yaml']) {
116
+ for (const name of ['.sdd.yaml', '.sdd-spec-root', '.sdd-workspace.yaml', '.gitignore']) {
313
117
  const target = path.join(repoRoot, name);
314
- if (!fs.existsSync(target)) continue;
315
- if (options.mode === MODE.FULL) {
316
- addOp(plan, OP.DELETE_FILE, target, 'SDD 用户配置');
317
- } else {
318
- addOp(plan, OP.KEEP, target, '用户配置,保留');
319
- }
118
+ if (fs.existsSync(target)) addOp(plan, OP.KEEP, target, '保留关联配置与隐私数据忽略规则');
320
119
  }
321
-
322
- // Git hooks:只删含受管标记的
323
- const hooksDir = path.join(repoRoot, '.git', 'hooks');
120
+ // 与安装器一样尊重 Hook 管理器、worktree 和自定义 Hook。
121
+ if (!workspaceLayout.isGitRepo(repoRoot) || sddConfig.gitConfig(repoRoot, 'core.hooksPath')) return;
122
+ const known = require('./managed-hook-hashes.json');
324
123
  for (const name of ['commit-msg', 'pre-commit', 'pre-push']) {
325
- const target = path.join(hooksDir, name);
326
- if (!fs.existsSync(target)) continue;
327
- let content = '';
124
+ let target;
328
125
  try {
329
- content = fs.readFileSync(target, 'utf8');
330
- } catch {
126
+ const value = execFileSync('git', ['rev-parse', '--git-path', `hooks/${name}`], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
127
+ target = path.resolve(repoRoot, value);
128
+ } catch { continue; }
129
+ if (!target.startsWith(path.resolve(repoRoot) + path.sep)) {
130
+ addOp(plan, OP.KEEP, target, '其他 worktree 共用的 Git Hook;请从主仓库管理');
331
131
  continue;
332
132
  }
333
- if (content.includes(DEFAULT_HOOK_MARKER)) {
334
- addOp(plan, OP.DELETE_FILE, target, 'SDD shell hook(受管标记)');
335
- } else {
336
- addOp(plan, OP.KEEP, target, '非 SDD 受管 hook,保留');
337
- }
338
- }
339
-
340
- const gitignore = path.join(repoRoot, '.gitignore');
341
- if (fs.existsSync(gitignore) && fs.readFileSync(gitignore, 'utf8').includes(GITIGNORE_MARKER)) {
342
- addOp(plan, OP.STRIP_GITIGNORE, gitignore, '摘除 SDD 忽略块');
343
- }
344
-
345
- if (workspaceLayout.isGitRepo(repoRoot)) {
346
- const specPath = sddConfig.gitConfig(repoRoot, 'sdd.specPath');
347
- if (specPath) {
348
- addOp(plan, OP.UNLINK_GIT_CONFIG, repoRoot, `git config --local sdd.specPath = ${specPath}`);
349
- }
133
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
134
+ if (!stat || !stat.isFile() || !fs.lstatSync(path.dirname(target)).isDirectory()) continue;
135
+ const hash = content => crypto.createHash('sha256').update(content).digest('hex');
136
+ const current = hash(fs.readFileSync(target));
137
+ const template = hash(fs.readFileSync(path.join(packageRoot, 'templates/git-hooks', name)));
138
+ if (current === template || (known[name] || []).includes(current)) addOp(plan, OP.DELETE_FILE, target, '完整匹配已发布的 SDD Hook');
139
+ else addOp(plan, OP.KEEP, target, '自定义或未知 Hook,请自行审阅其中的 SDD 调用');
350
140
  }
351
141
  }
352
142
 
@@ -356,19 +146,21 @@ function planCodeRepo(plan, repoRoot, options) {
356
146
  * @param {object} options
357
147
  * @param {string} options.cwd 工作目录(代码仓 / 工作区根)
358
148
  * @param {string[]} [options.tools] 要卸载的编辑器,缺省=全部
359
- * @param {string} [options.mode] MODE.TOOLS_ONLY(默认)| MODE.FULL
149
+ * @param {string} [options.mode] MODE.TOOLS_ONLY(唯一支持的方式)
360
150
  * @param {string} [options.specRoot] 已确认的 spec 包裹包目录(CLI 交互层选定后传入);
361
151
  * 缺省时仅按权威来源(.sdd-spec-root → git config)保守定位
362
152
  * @returns {{ok: boolean, plan: object, specRoot: string|null, message?: string}}
363
153
  */
364
154
  function buildUninstallPlan(options = {}) {
365
155
  const cwd = path.resolve(options.cwd || process.cwd());
366
- const mode = options.mode === MODE.FULL ? MODE.FULL : MODE.TOOLS_ONLY;
156
+ if (options.mode && options.mode !== MODE.TOOLS_ONLY) throw new Error('仅支持工具卸载;不允许删除 Spec 文档、配置和历史数据');
157
+ const mode = MODE.TOOLS_ONLY;
367
158
  const tools = Array.isArray(options.tools) && options.tools.length
368
159
  ? options.tools
369
160
  : listToolProfileIds();
370
161
 
371
162
  const plan = createPlan(cwd);
163
+ plan.tools = tools;
372
164
 
373
165
  // 编辑器专属产物:工作区根 / 当前目录
374
166
  for (const toolKey of tools) {
@@ -384,11 +176,18 @@ function buildUninstallPlan(options = {}) {
384
176
  : resolveSpecRootForUninstall(cwd);
385
177
  if (specRoot) {
386
178
  plan.specRoot = path.resolve(specRoot);
387
- planSpecPackage(plan, specRoot, { mode });
179
+ const relative = path.relative(cwd, specRoot);
180
+ const sharedExternal = relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative);
181
+ if (sharedExternal) {
182
+ addOp(plan, OP.KEEP, specRoot, '工作目录外的共享 Spec,仅卸载当前代码仓工具');
183
+ warn(plan, '共享 Spec 位于当前目录之外,运行工具与文档均保留;如需卸载共享工具,请从所属工作区操作');
184
+ } else {
185
+ planSpecPackage(plan, specRoot);
186
+ planCodeRepo(plan, specRoot);
187
+ }
388
188
  // spec 仓自身的 .git/hooks(init 装的 commit-msg / pre-commit / pre-push)
389
189
  // 是工具产物,须摘除 —— 与代码仓共用同一套受管标记判据,
390
190
  // 非受管 hook 与 .git 其余内容一律保留。
391
- planCodeRepo(plan, specRoot, { mode });
392
191
  } else {
393
192
  warn(plan, '未定位到 spec 包裹包:spec 侧产物(skywalk-sdd/、openspec/ 等)未纳入计划');
394
193
  }
@@ -409,10 +208,30 @@ function buildUninstallPlan(options = {}) {
409
208
  if (specRoot) specDirs.add(path.resolve(specRoot));
410
209
  for (const repo of layout.codeRepos) {
411
210
  if (specDirs.has(path.resolve(repo.abs))) continue;
412
- planCodeRepo(plan, repo.abs, { mode });
211
+ if (!specRoot || maintenance.associationConflict(repo.abs, specRoot)) {
212
+ warn(plan, `保留代码仓 ${repo.name}:未确认属于所选 Spec`);
213
+ continue;
214
+ }
215
+ const linked = sddConfig.gitConfig(repo.abs, 'sdd.specPath');
216
+ const yaml = sddConfig.loadSddYaml(repo.abs);
217
+ if (!linked && !yaml.ok) continue;
218
+ planCodeRepo(plan, repo.abs);
413
219
  }
414
220
  }
415
221
 
222
+ // 不沿中间符号链接删除文件;同一个文件仅处理一次。
223
+ const seen = new Set();
224
+ plan.ops = plan.ops.filter(item => {
225
+ const key = item.op + ':' + item.target;
226
+ if (seen.has(key)) return false;
227
+ seen.add(key);
228
+ if (item.op === OP.KEEP) return true;
229
+ const root = specRoot && item.target.startsWith(path.resolve(specRoot) + path.sep) ? path.resolve(specRoot) : cwd;
230
+ // Git worktree 的 hooks 位于 common git dir,已单独校验完整内容。
231
+ if (item.reason !== '完整匹配已发布的 SDD Hook' && !maintenance.regularFileWithin(root, path.relative(root, item.target))) return false;
232
+ item.digest = crypto.createHash('sha256').update(fs.readFileSync(item.target)).digest('hex');
233
+ return true;
234
+ });
416
235
  return { ok: true, plan, specRoot: specRoot || null, mode };
417
236
  }
418
237
 
@@ -527,187 +346,56 @@ function providerFromSettingsPath(settingsPath) {
527
346
  return /\.claude[\\/]/.test(settingsPath) ? PROVIDERS.CLAUDE : PROVIDERS.CODEBUDDY;
528
347
  }
529
348
 
530
- /**
531
- * 执行卸载计划
532
- *
533
- * @param {object} plan buildUninstallPlan 产出的 plan
534
- * @param {object} [options]
535
- * @param {boolean} [options.dryRun] 只打印,不落盘
536
- * @param {function} [options.log] 日志输出函数
537
- * @returns {{ok: boolean, applied: number, skipped: number, errors: string[]}}
538
- */
349
+ /** 执行前重新校验白名单和文件快照,拒绝旧全量计划以及预览后的变更。 */
539
350
  function applyUninstallPlan(plan, options = {}) {
540
- const dryRun = Boolean(options.dryRun);
541
351
  const log = typeof options.log === 'function' ? options.log : console.log;
542
352
  const errors = [];
353
+ if (!plan || !plan.root || !Array.isArray(plan.ops)) return { ok: false, applied: 0, skipped: 0, errors: ['卸载计划无效,请重新预览'] };
354
+ const fresh = buildUninstallPlan({ cwd: plan.root, specRoot: plan.specRoot, tools: plan.tools }).plan;
355
+ const allowed = new Map(fresh.ops.filter(item => item.op !== OP.KEEP).map(item => [item.op + ':' + item.target, item]));
356
+ for (const item of plan.ops) {
357
+ if (item.op === OP.KEEP) continue;
358
+ const current = allowed.get(item.op + ':' + item.target);
359
+ if (!current || !item.digest || current.digest !== item.digest) errors.push(`${item.target}: 范围或内容已变化,请重新预览`);
360
+ }
361
+ if (errors.length) return { ok: false, applied: 0, skipped: 0, errors };
543
362
  let applied = 0;
544
363
  let skipped = 0;
545
-
546
364
  for (const item of plan.ops) {
547
- if (item.op === OP.KEEP) {
548
- skipped += 1;
549
- log(` · 保留 ${path.basename(item.target)}(${item.reason})`);
550
- continue;
551
- }
552
-
365
+ if (item.op === OP.KEEP) { skipped++; continue; }
553
366
  try {
554
- switch (item.op) {
555
- case OP.DELETE_FILE:
556
- if (fs.existsSync(item.target)) {
557
- if (!dryRun) fs.unlinkSync(item.target);
558
- applied += 1;
559
- log(` ${dryRun ? '将删除' : '✓ 已删除'} 文件 ${item.target}`);
560
- }
561
- break;
562
-
563
- case OP.DELETE_DIR:
564
- if (fs.existsSync(item.target)) {
565
- const count = listFilesRecursive(item.target).length;
566
- if (!dryRun) fs.rmSync(item.target, { recursive: true, force: true });
567
- applied += 1;
568
- log(` ${dryRun ? '将删除' : '✓ 已删除'} 目录 ${item.target}(${count} 文件)`);
569
- }
570
- break;
571
-
572
- case OP.STRIP_HOOKS: {
573
- if (!fs.existsSync(item.target)) break;
574
- if (dryRun) {
575
- applied += 1;
576
- log(` 将摘除 ${item.target} 中的 SDD 受管 hook(保留其余配置)`);
577
- break;
578
- }
579
- const res = stripSettingsHooks(item.target, providerFromSettingsPath(item.target));
580
- if (!res.ok) {
581
- errors.push(`${item.target}: ${res.error}`);
582
- log(` ⚠️ ${item.target}: ${res.error}`);
583
- } else if (res.changed) {
584
- applied += 1;
585
- log(` ✓ 已摘除 ${item.target} 中的 SDD 受管 hook`);
586
- } else {
587
- // 无受管 hook 可摘 → 计为保留,保证重复卸载 applied 归零(幂等)
588
- skipped += 1;
589
- log(` · 保留 ${item.target}(无 SDD 受管 hook)`);
590
- }
591
- break;
592
- }
593
-
594
- case OP.STRIP_GITIGNORE: {
595
- if (!fs.existsSync(item.target)) break;
596
- if (dryRun) {
597
- applied += 1;
598
- log(` 将摘除 ${item.target} 中的 SDD 忽略块(保留其余行)`);
599
- break;
600
- }
601
- const res = stripGitignoreBlock(item.target);
602
- if (res.changed) {
603
- fs.writeFileSync(item.target, res.next, 'utf8');
604
- applied += 1;
605
- log(` ✓ 已摘除 ${item.target} 中的 SDD 忽略块(${res.removed} 行)`);
606
- } else {
607
- skipped += 1;
608
- log(` · 保留 ${item.target}(无 SDD 块可摘)`);
609
- }
610
- break;
611
- }
612
-
613
- case OP.UNLINK_GIT_CONFIG: {
614
- if (dryRun) {
615
- applied += 1;
616
- log(' 将删除 git config --local sdd.specPath');
617
- break;
618
- }
619
- const res = sddConfig.unsetSpecPath(item.target);
620
- if (!res.ok) {
621
- errors.push(`${item.target}: ${res.message || '删除 sdd.specPath 失败'}`);
622
- log(' ⚠️ 删除 git config --local sdd.specPath 失败');
623
- } else if (res.removed) {
624
- applied += 1;
625
- log(' ✓ 已删除 git config --local sdd.specPath');
626
- } else {
627
- // 配置项本就不存在 → 保留,保证幂等
628
- skipped += 1;
629
- log(' · 保留 git config(sdd.specPath 本就不存在)');
630
- }
631
- break;
632
- }
633
-
634
- default:
635
- break;
367
+ // 执行时再检查一次,不接受被替换为链接的文件。
368
+ const stat = fs.lstatSync(item.target);
369
+ if (!stat.isFile() || crypto.createHash('sha256').update(fs.readFileSync(item.target)).digest('hex') !== item.digest) throw new Error('文件已变化,请重新预览');
370
+ if (options.dryRun) { log(` 将处理 ${item.target}`); applied++; continue; }
371
+ if (item.op === OP.DELETE_FILE) { fs.unlinkSync(item.target); applied++; }
372
+ else if (item.op === OP.STRIP_HOOKS) {
373
+ const result = stripSettingsHooks(item.target, providerFromSettingsPath(item.target));
374
+ if (!result.ok) throw new Error(result.error);
375
+ if (result.changed) applied++; else skipped++;
636
376
  }
637
- } catch (error) {
638
- errors.push(`${item.target}: ${error.message}`);
639
- log(` ⚠️ ${item.target}: ${error.message}`);
640
- }
377
+ } catch (error) { errors.push(`${item.target}: ${error.message}`); }
641
378
  }
642
-
643
- // 清理变空的目录:仅清理由本计划删除行为导致的空壳目录。
644
- //
645
- // 两条安全边界(缺一不可):
646
- // 1) 绝不清理 plan.root(工作目录)及其祖先 —— 候选父目录是逐级向上收集的,
647
- // 没有这道闸门时,一个空的工作目录会被 rmdir 掉,甚至连带其父目录。
648
- // 2) KEEP 项(如 skywalk-sdd/events、state)即使变空也必须保留,
649
- // 否则会把用户明确要保留的运行时数据目录一并抹掉。
650
- if (!dryRun) {
651
- const rootAbs = plan.root ? path.resolve(plan.root) : null;
652
- const protectedDirs = new Set(
653
- plan.ops.filter(i => i.op === OP.KEEP).map(i => path.resolve(i.target)),
654
- );
655
-
656
- const isProtected = (dir) => {
657
- const abs = path.resolve(dir);
658
- // 工作目录本身及其祖先目录不可清理
659
- if (rootAbs && (abs === rootAbs || rootAbs.startsWith(abs + path.sep))) return true;
660
- if (protectedDirs.has(abs)) return true;
661
- // 祖先目录被保护时,其子目录同样不可清理
662
- for (const keep of protectedDirs) {
663
- if (keep.startsWith(abs + path.sep)) return true;
664
- }
665
- return false;
666
- };
667
-
668
- const candidates = new Set();
669
- for (const item of plan.ops) {
670
- if (item.op !== OP.DELETE_FILE && item.op !== OP.DELETE_DIR) continue;
379
+ // 只收起因工具文件删除而变空的目录;根目录、Spec、数据目录保持原样。
380
+ if (!options.dryRun) {
381
+ const roots = [plan.root, plan.specRoot].filter(Boolean).map(p => path.resolve(p));
382
+ const kept = plan.ops.filter(i => i.op === OP.KEEP).map(i => path.resolve(i.target));
383
+ for (const item of plan.ops.filter(i => i.op === OP.DELETE_FILE && !i.target.includes(`${path.sep}.git${path.sep}`))) {
671
384
  let dir = path.dirname(item.target);
672
- // 逐级向上收集候选父目录(深度优先,先深后浅)
673
- for (let i = 0; i < 4 && dir && dir !== path.dirname(dir); i += 1) {
674
- candidates.add(dir);
675
- dir = path.dirname(dir);
676
- }
677
- }
678
-
679
- const ordered = [...candidates].sort((a, b) => b.length - a.length);
680
- for (const dir of ordered) {
681
- if (isProtected(dir)) continue;
682
- if (!fs.existsSync(dir) || !isEmptyDir(dir)) continue;
683
- try {
385
+ while (roots.some(root => dir.startsWith(root + path.sep)) && !roots.includes(dir)) {
386
+ if (kept.some(p => p === dir || p.startsWith(dir + path.sep))) break;
387
+ if (!isEmptyDir(dir)) break;
684
388
  fs.rmdirSync(dir);
685
- } catch { /* 目录非空或权限不足,忽略 */ }
686
- }
687
-
688
- // spec 仓目录本身:产物清空后若已无任何文件(含 .git 内),一并删除。
689
- // 判据是「递归无文件」而非「无子项」——.git/ 即使只剩空目录结构也算残留。
690
- // 有文件则保留:目录名由用户 --spec-path 指定,工具不擅自删非空目录。
691
- const specAbs = plan.specRoot ? path.resolve(plan.specRoot) : null;
692
- if (specAbs && fs.existsSync(specAbs) && isProtected(specAbs) === false) {
693
- if (listFilesRecursive(specAbs).length === 0) {
694
- try {
695
- fs.rmSync(specAbs, { recursive: true, force: true });
696
- applied += 1;
697
- log(` ✓ 已删除 spec 仓目录 ${specAbs}(无残留文件)`);
698
- } catch { /* 权限不足等,忽略 */ }
389
+ dir = path.dirname(dir);
699
390
  }
700
391
  }
701
392
  }
702
-
703
393
  return { ok: errors.length === 0, applied, skipped, errors };
704
394
  }
705
395
 
706
396
  /** 卸载方式的中文标签(供 CLI 层复用,避免文案漂移) */
707
- function modeLabel(mode) {
708
- return mode === MODE.FULL
709
- ? '全量卸载(含用户配置与运行时数据)'
710
- : '工具内容卸载(保留用户配置)';
397
+ function modeLabel() {
398
+ return '卸载工具(保留 Spec 文档、配置与历史数据)';
711
399
  }
712
400
 
713
401
  /**
@@ -719,7 +407,7 @@ function modeLabel(mode) {
719
407
  * @param {object} [opts]
720
408
  * @param {string} [opts.cwd] 工作目录(代码仓 / 工作区根),缺省 process.cwd()
721
409
  * @param {string[]} [opts.tools] 要卸载的编辑器 id 列表,缺省=全部
722
- * @param {string} [opts.mode] MODE.TOOLS_ONLY(默认)| MODE.FULL
410
+ * @param {string} [opts.mode] MODE.TOOLS_ONLY(唯一支持的方式)
723
411
  * @param {boolean} [opts.dryRun] 只打印计划,不落盘
724
412
  * @param {object} [opts.plan] 复用调用方已算好的计划(避免重复计算)
725
413
  * @param {boolean} [opts.showWarnings] 是否打印 plan.warnings,缺省 true
@@ -727,7 +415,8 @@ function modeLabel(mode) {
727
415
  */
728
416
  function main(opts = {}) {
729
417
  const cwd = opts.cwd || process.cwd();
730
- const mode = opts.mode === MODE.FULL ? MODE.FULL : MODE.TOOLS_ONLY;
418
+ if (opts.mode && opts.mode !== MODE.TOOLS_ONLY) throw new Error('仅支持工具卸载');
419
+ const mode = MODE.TOOLS_ONLY;
731
420
 
732
421
  const plan = opts.plan || buildUninstallPlan({ cwd, tools: opts.tools, mode }).plan;
733
422
  const result = applyUninstallPlan(plan, { dryRun: Boolean(opts.dryRun) });
@@ -740,7 +429,7 @@ function main(opts = {}) {
740
429
  }
741
430
 
742
431
  console.log('');
743
- console.log(`✅ 完成:处理 ${result.applied} 项,保留 ${result.skipped} 项`);
432
+ console.log(`${result.ok ? '✅ 完成' : '❌ 未完整卸载'}:处理 ${result.applied} 项,保留 ${result.skipped} 项`);
744
433
  if (result.errors.length) {
745
434
  console.log(`⚠️ ${result.errors.length} 项失败:`);
746
435
  for (const e of result.errors) console.log(` - ${e}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kld-sdd",
3
- "version": "2.7.8-4",
3
+ "version": "2.7.8-5",
4
4
  "description": "KLD SDD OpenSpec 项目初始化工具 - 一键部署 SDD skills",
5
5
  "main": "lib/init.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "bench:check": "node test/benchmark-check.cjs",
12
- "test": "node --test test/delivery-hooks.test.cjs test/execution-friction.test.cjs test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/prompt-spec-path.cjs && node test/spec-detect.cjs && node test/workspace-layout.cjs && node test/sync-repos-spec-choice.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/deploy-strategy.cjs && node test/uninstall.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs test/runtime/usage-reporter.test.cjs test/runtime/usage-reporting.test.cjs test/runtime/git-identity.test.cjs test/runtime/kb-state-location.test.cjs && node test/runtime/metrics-v3.test.cjs && node test/runtime/fixback.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
12
+ "test": "node --test test/delivery-hooks.test.cjs test/execution-friction.test.cjs test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/prompt-spec-path.cjs && node test/spec-detect.cjs && node test/workspace-layout.cjs && node test/sync-repos-spec-choice.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/deploy-strategy.cjs && node test/uninstall.cjs && node --test test/maintenance.test.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs test/runtime/usage-reporter.test.cjs test/runtime/usage-reporting.test.cjs test/runtime/git-identity.test.cjs test/runtime/kb-state-location.test.cjs && node test/runtime/metrics-v3.test.cjs && node test/runtime/fixback.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
13
13
  },
14
14
  "keywords": [
15
15
  "kld",
@@ -46,5 +46,8 @@
46
46
  "skywalk-sdd/kb-sync-identity.cjs",
47
47
  "README.md",
48
48
  "USABILITY.md"
49
- ]
49
+ ],
50
+ "sddMaintenance": {
51
+ "toolOnly": 1
52
+ }
50
53
  }
@@ -167,7 +167,25 @@ function writeWorkspaceFile(workspaceRoot, layout) {
167
167
  '',
168
168
  ];
169
169
  const filePath = path.join(workspaceRoot, WORKSPACE_FILE);
170
- fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
170
+ if (fs.existsSync(filePath)) {
171
+ // sync-repos 只追加新仓;既有行、注释、自定义字段和暂时离线的仓库保留。
172
+ const raw = fs.readFileSync(filePath, 'utf8');
173
+ const saved = parseWorkspaceYaml(raw);
174
+ if (!saved.spec_path || path.resolve(workspaceRoot, saved.spec_path) !== path.resolve(workspaceRoot, specRel)) {
175
+ throw new Error('已有多仓清单的 Spec 指向不一致,请先核对配置');
176
+ }
177
+ const known = new Set(saved.code_repos.map(rel => path.resolve(workspaceRoot, rel.replace(/^(["'])(.*)\1$/, '$2'))));
178
+ const additions = codeRels.filter(rel => !known.has(path.resolve(workspaceRoot, rel)));
179
+ if (!additions.length) return filePath;
180
+ const newline = raw.includes('\r\n') ? '\r\n' : '\n';
181
+ const existing = raw.split(/\r?\n/);
182
+ const header = existing.findIndex(line => /^code_repos:\s*(?:#.*)?$/.test(line));
183
+ if (header < 0) throw new Error('已有多仓清单缺少 code_repos 列表,请先修复配置');
184
+ existing.splice(header + 1, 0, ...additions.map(rel => ` - ${rel}`));
185
+ fs.writeFileSync(filePath, existing.join(newline), 'utf8');
186
+ } else {
187
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
188
+ }
171
189
  return filePath;
172
190
  }
173
191