@mison/ling 1.1.0 → 1.2.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/bin/ling-cli.js CHANGED
@@ -5,7 +5,7 @@ const os = require("os");
5
5
  const path = require("path");
6
6
 
7
7
  const pkg = require("../package.json");
8
- const { readGlobalNpmDependencies, cloneBranchAgentDir } = require("./utils");
8
+ const { readGlobalNpmDependencies, cloneBranchAgentDir, cloneBranchSpecDir } = require("./utils");
9
9
  const ManifestManager = require("./utils/manifest");
10
10
  const AtomicWriter = require("./utils/atomic-writer");
11
11
  const CodexBuilder = require("./core/builder");
@@ -19,7 +19,8 @@ const PRIMARY_CLI_NAME = "ling";
19
19
  const WORKSPACE_INDEX_VERSION = 2;
20
20
  const UPSTREAM_GLOBAL_PACKAGE = "@vudovn/ag-kit";
21
21
  const TOOLKIT_PACKAGE_NAMES = new Set(["@mison/ling", "@mison/ag-kit-cn", "antigravity-kit-cn", "antigravity-kit"]);
22
- const SUPPORTED_TARGETS = ["gemini", "codex"];
22
+ const SUPPORTED_TARGETS = ["gemini", "antigravity", "codex"];
23
+ const SHARED_AGENT_TARGETS = ["gemini", "antigravity"];
23
24
  const LEGACY_INDEX_TARGET_ALIASES = {
24
25
  full: "gemini",
25
26
  };
@@ -37,6 +38,8 @@ const GLOBAL_TARGET_DESTINATIONS = {
37
38
  rootParts: [".gemini", "skills"],
38
39
  skillsParts: [".gemini", "skills"],
39
40
  },
41
+ ],
42
+ antigravity: [
40
43
  {
41
44
  id: "antigravity",
42
45
  rootParts: [".gemini", "antigravity"],
@@ -55,6 +58,9 @@ const QUIET_STATUS_EXIT_CODES = {
55
58
  const SPEC_STATE_VERSION = 1;
56
59
  const SPEC_SKILL_NAMES = ["harness-engineering", "cybernetic-systems-engineering"];
57
60
  const VERSION_TAG_PREFIX = "ling-";
61
+ const SPEC_TEMPLATE_REQUIRED_FILES = ["issues.template.csv", "driver-prompt.md", "review-report.md", "phase-acceptance.md", "handoff.md"];
62
+ const SPEC_REFERENCE_REQUIRED_FILES = ["README.md", "harness-engineering-digest.md", "gda-framework.md", "cse-quickstart.md"];
63
+ const SPEC_PROFILE_REQUIRED_FILES = ["codex/AGENTS.spec.md", "codex/ling.spec.rules.md", "gemini/GEMINI.spec.md"];
58
64
 
59
65
  function nowISO() {
60
66
  return new Date().toISOString();
@@ -167,11 +173,13 @@ function printUsage() {
167
173
  console.log(` ${PRIMARY_CLI_NAME} update [--path <dir>] [--branch <name>] [--target <name>|--targets <a,b>] [--no-index] [--quiet] [--dry-run]`);
168
174
  console.log(` ${PRIMARY_CLI_NAME} update-all [--branch <name>] [--targets <a,b>] [--prune-missing] [--quiet] [--dry-run]`);
169
175
  console.log(` ${PRIMARY_CLI_NAME} doctor [--path <dir>] [--target <name>|--targets <a,b>] [--fix] [--quiet]`);
170
- console.log(` ${PRIMARY_CLI_NAME} global sync [--target <name>|--targets <a,b>] [--branch <name>] [--quiet] [--dry-run] # 默认同步 codex + gemini(cli+antigravity)`);
176
+ console.log(` ${PRIMARY_CLI_NAME} global sync [--target <name>|--targets <a,b>] [--branch <name>] [--quiet] [--dry-run] # 默认同步 codex + gemini + antigravity`);
171
177
  console.log(` ${PRIMARY_CLI_NAME} global status [--quiet]`);
172
178
  console.log(` ${PRIMARY_CLI_NAME} spec enable [--target <name>|--targets <a,b>] [--quiet] [--dry-run]`);
173
179
  console.log(` ${PRIMARY_CLI_NAME} spec disable [--target <name>|--targets <a,b>] [--quiet] [--dry-run]`);
174
180
  console.log(` ${PRIMARY_CLI_NAME} spec status [--quiet]`);
181
+ console.log(` ${PRIMARY_CLI_NAME} spec init [--path <dir>] [--spec-workspace] [--csv-only] [--target <name>|--targets <a,b>] [--branch <name>] [--force] [--non-interactive] [--no-index] [--quiet] [--dry-run]`);
182
+ console.log(` ${PRIMARY_CLI_NAME} spec doctor [--path <dir>] [--spec-workspace] [--quiet]`);
175
183
  console.log(` ${PRIMARY_CLI_NAME} exclude list [--quiet]`);
176
184
  console.log(` ${PRIMARY_CLI_NAME} exclude add --path <dir> [--dry-run] [--quiet]`);
177
185
  console.log(` ${PRIMARY_CLI_NAME} exclude remove --path <dir> [--dry-run] [--quiet]`);
@@ -197,6 +205,8 @@ function parseArgs(argv) {
197
205
  nonInteractive: false,
198
206
  noIndex: false,
199
207
  fix: false,
208
+ csvOnly: false,
209
+ specWorkspace: false,
200
210
  subcommand: "",
201
211
  path: "",
202
212
  branch: "",
@@ -247,6 +257,12 @@ function parseArgs(argv) {
247
257
  } else if (arg === "--fix") {
248
258
  providedFlags.push(arg);
249
259
  options.fix = true;
260
+ } else if (arg === "--csv-only") {
261
+ providedFlags.push(arg);
262
+ options.csvOnly = true;
263
+ } else if (arg === "--spec-workspace") {
264
+ providedFlags.push(arg);
265
+ options.specWorkspace = true;
250
266
  } else if (arg === "--path") {
251
267
  providedFlags.push(arg);
252
268
  if (i + 1 >= argv.length) {
@@ -290,6 +306,8 @@ const COMMAND_ALLOWED_FLAGS = {
290
306
  "spec:enable": ["--target", "--targets", "--quiet", "--dry-run"],
291
307
  "spec:disable": ["--target", "--targets", "--quiet", "--dry-run"],
292
308
  "spec:status": ["--quiet"],
309
+ "spec:init": ["--force", "--path", "--spec-workspace", "--branch", "--csv-only", "--target", "--targets", "--non-interactive", "--no-index", "--quiet", "--dry-run"],
310
+ "spec:doctor": ["--path", "--spec-workspace", "--quiet"],
293
311
  "exclude:list": ["--quiet"],
294
312
  "exclude:add": ["--path", "--dry-run", "--quiet"],
295
313
  "exclude:remove": ["--path", "--dry-run", "--quiet"],
@@ -382,6 +400,12 @@ function pathCompareKey(inputPath) {
382
400
  return normalized;
383
401
  }
384
402
 
403
+ function findWorkspaceRecord(index, workspaceRoot) {
404
+ const normalizedPath = normalizeAbsolutePath(workspaceRoot);
405
+ const targetKey = pathCompareKey(normalizedPath);
406
+ return (index.workspaces || []).find((item) => pathCompareKey(item.path) === targetKey) || null;
407
+ }
408
+
385
409
  function normalizePathList(items) {
386
410
  const map = new Map();
387
411
  for (const item of items) {
@@ -620,6 +644,95 @@ function writeWorkspaceIndex(indexPath, index) {
620
644
  fs.writeFileSync(indexPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
621
645
  }
622
646
 
647
+ function getWorkspaceInstallStatePath(workspaceRoot) {
648
+ return path.join(normalizeAbsolutePath(workspaceRoot), ".ling", "install-state.json");
649
+ }
650
+
651
+ function createEmptyWorkspaceInstallState() {
652
+ return {
653
+ version: 1,
654
+ updatedAt: "",
655
+ targets: {},
656
+ };
657
+ }
658
+
659
+ function readWorkspaceInstallState(workspaceRoot) {
660
+ const statePath = getWorkspaceInstallStatePath(workspaceRoot);
661
+ if (!fs.existsSync(statePath)) {
662
+ return { statePath, state: createEmptyWorkspaceInstallState() };
663
+ }
664
+
665
+ const raw = fs.readFileSync(statePath, "utf8").trim();
666
+ if (!raw) {
667
+ return { statePath, state: createEmptyWorkspaceInstallState() };
668
+ }
669
+
670
+ let parsed;
671
+ try {
672
+ parsed = JSON.parse(raw);
673
+ } catch (_err) {
674
+ return { statePath, state: createEmptyWorkspaceInstallState() };
675
+ }
676
+
677
+ const state = createEmptyWorkspaceInstallState();
678
+ state.updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : "";
679
+ if (parsed && parsed.targets && typeof parsed.targets === "object") {
680
+ for (const [targetName, targetState] of Object.entries(parsed.targets)) {
681
+ const normalizedTargetName = normalizeIndexTargetName(targetName);
682
+ const normalizedTargetState = normalizeTargetState(targetState);
683
+ if (normalizedTargetName && normalizedTargetState) {
684
+ state.targets[normalizedTargetName] = normalizedTargetState;
685
+ }
686
+ }
687
+ }
688
+
689
+ return { statePath, state };
690
+ }
691
+
692
+ function writeWorkspaceInstallState(statePath, state) {
693
+ const payload = {
694
+ version: 1,
695
+ updatedAt: state.updatedAt || nowISO(),
696
+ targets: state.targets || {},
697
+ };
698
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
699
+ fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
700
+ }
701
+
702
+ function recordWorkspaceInstallTargets(workspaceRoot, targetNames, options) {
703
+ const logicalTargets = normalizeTargets(targetNames).filter((targetName) => SHARED_AGENT_TARGETS.includes(targetName));
704
+ if (logicalTargets.length === 0) {
705
+ return;
706
+ }
707
+
708
+ if (options.dryRun) {
709
+ log(options, `[dry-run] 将写入工作区安装状态: ${getWorkspaceInstallStatePath(workspaceRoot)} [${logicalTargets.join(", ")}]`);
710
+ return;
711
+ }
712
+
713
+ const { statePath, state } = readWorkspaceInstallState(workspaceRoot);
714
+ const timestamp = nowISO();
715
+ for (const targetName of logicalTargets) {
716
+ const prev = normalizeTargetState(state.targets[targetName]) || {
717
+ version: "",
718
+ installedAt: "",
719
+ updatedAt: "",
720
+ };
721
+ state.targets[targetName] = {
722
+ version: pkg.version,
723
+ installedAt: prev.installedAt || timestamp,
724
+ updatedAt: timestamp,
725
+ };
726
+ }
727
+ state.updatedAt = timestamp;
728
+ writeWorkspaceInstallState(statePath, state);
729
+ }
730
+
731
+ function resolveWorkspaceInstallStateTargets(workspaceRoot) {
732
+ const { state } = readWorkspaceInstallState(workspaceRoot);
733
+ return normalizeTargets(Object.keys(state.targets || {}));
734
+ }
735
+
623
736
  function sleepSync(ms) {
624
737
  const buffer = new SharedArrayBuffer(4);
625
738
  const view = new Int32Array(buffer);
@@ -899,19 +1012,41 @@ function normalizeTargets(rawTargets) {
899
1012
  return result;
900
1013
  }
901
1014
 
1015
+ function resolveIndexedWorkspaceTargets(workspaceRoot) {
1016
+ try {
1017
+ const { index } = readWorkspaceIndex();
1018
+ const record = findWorkspaceRecord(index, workspaceRoot);
1019
+ if (!record) {
1020
+ return [];
1021
+ }
1022
+ return normalizeTargets(Object.keys(record.targets || {}));
1023
+ } catch (_err) {
1024
+ return [];
1025
+ }
1026
+ }
1027
+
902
1028
  function detectInstalledTargets(workspaceRoot) {
903
1029
  const targets = [];
1030
+ const localTargets = resolveWorkspaceInstallStateTargets(workspaceRoot);
1031
+ const indexedTargets = resolveIndexedWorkspaceTargets(workspaceRoot);
904
1032
  if (fs.existsSync(path.join(workspaceRoot, ".agent"))) {
905
- targets.push("gemini");
1033
+ const sharedTargets = localTargets
1034
+ .filter((target) => SHARED_AGENT_TARGETS.includes(target))
1035
+ .concat(indexedTargets.filter((target) => SHARED_AGENT_TARGETS.includes(target)));
1036
+ if (sharedTargets.length > 0) {
1037
+ targets.push(...sharedTargets);
1038
+ } else {
1039
+ targets.push("gemini");
1040
+ }
906
1041
  }
907
1042
  if (fs.existsSync(path.join(workspaceRoot, ".agents")) || fs.existsSync(path.join(workspaceRoot, ".codex"))) {
908
1043
  targets.push("codex");
909
1044
  }
910
- return targets;
1045
+ return normalizeTargets(targets);
911
1046
  }
912
1047
 
913
1048
  function isTargetInstalled(workspaceRoot, targetName) {
914
- if (targetName === "gemini") {
1049
+ if (SHARED_AGENT_TARGETS.includes(targetName)) {
915
1050
  return fs.existsSync(path.join(workspaceRoot, ".agent"));
916
1051
  }
917
1052
  if (targetName === "codex") {
@@ -920,6 +1055,27 @@ function isTargetInstalled(workspaceRoot, targetName) {
920
1055
  return false;
921
1056
  }
922
1057
 
1058
+ function groupTargetsByInstallSurface(targets) {
1059
+ const normalizedTargets = normalizeTargets(targets);
1060
+ const groups = [];
1061
+ const sharedTargets = normalizedTargets.filter((target) => SHARED_AGENT_TARGETS.includes(target));
1062
+ if (sharedTargets.length > 0) {
1063
+ groups.push({
1064
+ installSurface: ".agent",
1065
+ adapterTarget: sharedTargets.includes("gemini") ? "gemini" : "antigravity",
1066
+ logicalTargets: sharedTargets,
1067
+ });
1068
+ }
1069
+ if (normalizedTargets.includes("codex")) {
1070
+ groups.push({
1071
+ installSurface: ".agents",
1072
+ adapterTarget: "codex",
1073
+ logicalTargets: ["codex"],
1074
+ });
1075
+ }
1076
+ return groups;
1077
+ }
1078
+
923
1079
  function setQuietStatusExitCode(state) {
924
1080
  process.exitCode = Object.prototype.hasOwnProperty.call(QUIET_STATUS_EXIT_CODES, state)
925
1081
  ? QUIET_STATUS_EXIT_CODES[state]
@@ -1015,8 +1171,11 @@ function evaluateGlobalState() {
1015
1171
  }
1016
1172
 
1017
1173
  function createAdapter(targetName, workspaceRoot, options) {
1018
- if (targetName === "gemini") {
1019
- return new GeminiAdapter(workspaceRoot, options);
1174
+ if (SHARED_AGENT_TARGETS.includes(targetName)) {
1175
+ return new GeminiAdapter(workspaceRoot, {
1176
+ ...options,
1177
+ targetName,
1178
+ });
1020
1179
  }
1021
1180
  if (targetName === "codex") {
1022
1181
  return new CodexAdapter(workspaceRoot, options);
@@ -1060,8 +1219,7 @@ function resolveTargetsForGlobalSync(options) {
1060
1219
  if (requested.length > 0) {
1061
1220
  return requested;
1062
1221
  }
1063
- // 保持 global sync 简洁:默认同步 codex + gemini;其中 gemini 会展开为 gemini-cli 与 antigravity。
1064
- return ["codex", "gemini"];
1222
+ return [...SUPPORTED_TARGETS];
1065
1223
  }
1066
1224
 
1067
1225
  function resolveAgentInstallSource(options) {
@@ -1228,7 +1386,7 @@ function planGlobalSyncTasks(targetName, agentDir) {
1228
1386
  };
1229
1387
  }
1230
1388
 
1231
- if (targetName === "gemini") {
1389
+ if (SHARED_AGENT_TARGETS.includes(targetName)) {
1232
1390
  const skillsRoot = path.join(agentDir, "skills");
1233
1391
  const skillNames = listSkillDirectories(skillsRoot);
1234
1392
  const tasks = [];
@@ -1310,7 +1468,7 @@ function applyGlobalSync(targetName, agentDir, timestamp, options) {
1310
1468
  }
1311
1469
  }
1312
1470
 
1313
- if (targetName === "gemini") {
1471
+ if (SHARED_AGENT_TARGETS.includes(targetName)) {
1314
1472
  const skillsRoot = path.join(agentDir, "skills");
1315
1473
  return syncGlobalSkillsFromRoot(targetName, skillsRoot, timestamp, options);
1316
1474
  }
@@ -1447,6 +1605,10 @@ function getSpecHomeDir() {
1447
1605
  return path.join(resolveGlobalRootDir(), ".ling", "spec");
1448
1606
  }
1449
1607
 
1608
+ function getSpecWorkspaceDir() {
1609
+ return path.join(resolveGlobalRootDir(), ".ling", "spec-workspace");
1610
+ }
1611
+
1450
1612
  function getSpecStatePath() {
1451
1613
  return path.join(getSpecHomeDir(), "state.json");
1452
1614
  }
@@ -1524,6 +1686,46 @@ function ensureBundledSpecResources() {
1524
1686
  }
1525
1687
  }
1526
1688
 
1689
+ function listMissingFiles(rootDir, requiredRelPaths) {
1690
+ if (!fs.existsSync(rootDir)) {
1691
+ return requiredRelPaths.map((rel) => rel);
1692
+ }
1693
+ return requiredRelPaths.filter((rel) => !fs.existsSync(path.join(rootDir, ...rel.split("/"))));
1694
+ }
1695
+
1696
+ function collectSpecOrphanIssues(specHome, hasStateFile) {
1697
+ const issues = [];
1698
+ const templatesDir = path.join(specHome, "templates");
1699
+ const referencesDir = path.join(specHome, "references");
1700
+ const profilesDir = path.join(specHome, "profiles");
1701
+
1702
+ if (fs.existsSync(templatesDir)) issues.push("Detected spec templates directory");
1703
+ if (fs.existsSync(referencesDir)) issues.push("Detected spec references directory");
1704
+ if (fs.existsSync(profilesDir)) issues.push("Detected spec profiles directory");
1705
+
1706
+ for (const targetName of SUPPORTED_TARGETS) {
1707
+ for (const destination of getGlobalDestinations(targetName)) {
1708
+ for (const skillName of SPEC_SKILL_NAMES) {
1709
+ if (fs.existsSync(path.join(destination.skillsRoot, skillName, "SKILL.md"))) {
1710
+ issues.push(`Detected spec skill: ${destination.id}/${skillName}`);
1711
+ }
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ if (issues.length === 0) {
1717
+ return [];
1718
+ }
1719
+
1720
+ if (!hasStateFile) {
1721
+ issues.unshift("Spec artifacts detected but state.json missing (run: ling spec enable to repair)");
1722
+ } else {
1723
+ issues.unshift("Spec artifacts detected but no targets enabled (run: ling spec enable to reconcile or clean manually)");
1724
+ }
1725
+
1726
+ return issues;
1727
+ }
1728
+
1527
1729
  function backupDirSnapshot(sourceDir, backupDir, options, label) {
1528
1730
  if (!fs.existsSync(sourceDir)) {
1529
1731
  return "";
@@ -1560,6 +1762,59 @@ function removeDirIfExists(targetDir, options, label) {
1560
1762
  log(options, `[clean] 已删除 ${label}: ${targetDir}`);
1561
1763
  }
1562
1764
 
1765
+ function atomicWriteFile(targetPath, content, options, label) {
1766
+ const targetDir = path.dirname(targetPath);
1767
+ if (options.dryRun) {
1768
+ log(options, `[dry-run] 将写入 ${label}: ${targetPath}`);
1769
+ return;
1770
+ }
1771
+ fs.mkdirSync(targetDir, { recursive: true });
1772
+ const tempPath = `${targetPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1773
+ fs.writeFileSync(tempPath, content, "utf8");
1774
+ try {
1775
+ if (fs.existsSync(targetPath)) {
1776
+ const backupPath = `${targetPath}.bak-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`;
1777
+ fs.renameSync(targetPath, backupPath);
1778
+ try {
1779
+ fs.renameSync(tempPath, targetPath);
1780
+ } catch (err) {
1781
+ try {
1782
+ fs.renameSync(backupPath, targetPath);
1783
+ } catch (restoreErr) {
1784
+ throw new Error(`临界失败: 无法将新版本写入目标且无法恢复旧版本。旧版本位于 ${backupPath}。错误: ${err.message}`);
1785
+ }
1786
+ throw err;
1787
+ }
1788
+ try {
1789
+ fs.rmSync(backupPath, { force: true });
1790
+ } catch (cleanupErr) {
1791
+ log(options, `[warn] 无法清理备份文件 ${backupPath}: ${cleanupErr.message}`);
1792
+ }
1793
+ return;
1794
+ }
1795
+ fs.renameSync(tempPath, targetPath);
1796
+ } catch (err) {
1797
+ if (fs.existsSync(tempPath)) {
1798
+ fs.rmSync(tempPath, { force: true });
1799
+ }
1800
+ throw new Error(`原子写入失败: ${err.message}`);
1801
+ }
1802
+ }
1803
+
1804
+ function backupFileSnapshot(sourcePath, backupPath, options, label) {
1805
+ if (!fs.existsSync(sourcePath)) {
1806
+ return "";
1807
+ }
1808
+ if (options.dryRun) {
1809
+ log(options, `[dry-run] 将备份 ${label}: ${sourcePath} -> ${backupPath}`);
1810
+ return backupPath;
1811
+ }
1812
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
1813
+ fs.copyFileSync(sourcePath, backupPath);
1814
+ log(options, `[backup] 已备份 ${label}: ${sourcePath} -> ${backupPath}`);
1815
+ return backupPath;
1816
+ }
1817
+
1563
1818
  async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) {
1564
1819
  const specHome = getSpecHomeDir();
1565
1820
  const assets = {
@@ -1571,14 +1826,37 @@ async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) {
1571
1826
  sourceDir: path.join(BUNDLED_SPEC_DIR, "references"),
1572
1827
  destDir: path.join(specHome, "references"),
1573
1828
  },
1829
+ profiles: {
1830
+ sourceDir: path.join(BUNDLED_SPEC_DIR, "profiles"),
1831
+ destDir: path.join(specHome, "profiles"),
1832
+ },
1574
1833
  };
1575
1834
 
1576
1835
  for (const [assetName, config] of Object.entries(assets)) {
1577
- if (state.assets[assetName] && state.assets[assetName].installedAt) {
1836
+ const existingAssetState = state.assets[assetName];
1837
+ const hasState = existingAssetState && existingAssetState.installedAt;
1838
+ const exists = fs.existsSync(config.destDir);
1839
+ const mode = normalizeSpecAssetMode(existingAssetState);
1840
+
1841
+ if (mode === "kept" && exists) {
1842
+ continue;
1843
+ }
1844
+
1845
+ const equal = exists ? areDirectoriesEqual(config.sourceDir, config.destDir) : false;
1846
+
1847
+ if (exists && equal) {
1848
+ if (!hasState) {
1849
+ log(options, `[skip] Spec ${assetName} 已存在且一致,视为已启用: ${config.destDir}`);
1850
+ state.assets[assetName] = {
1851
+ destPath: config.destDir,
1852
+ backupPath: "",
1853
+ installedAt: nowISO(),
1854
+ mode: "kept",
1855
+ };
1856
+ }
1578
1857
  continue;
1579
1858
  }
1580
1859
 
1581
- const exists = fs.existsSync(config.destDir);
1582
1860
  let action = exists ? "backup" : "";
1583
1861
  if (exists && prompter) {
1584
1862
  action = await prompter.resolveConflict({
@@ -1586,6 +1864,8 @@ async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) {
1586
1864
  label: `Spec ${assetName}`,
1587
1865
  path: config.destDir,
1588
1866
  });
1867
+ } else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
1868
+ action = "backup";
1589
1869
  }
1590
1870
 
1591
1871
  if (action === "keep") {
@@ -1650,26 +1930,59 @@ function restoreSpecAsset(assetState, options, label) {
1650
1930
  }
1651
1931
 
1652
1932
  async function enableSpecTarget(targetName, state, timestamp, options, prompter) {
1653
- if (state.targets[targetName]) {
1654
- log(options, `[skip] Spec 目标已启用: ${targetName}`);
1655
- return;
1656
- }
1657
-
1658
1933
  const destinations = getGlobalDestinations(targetName);
1659
- const targetState = {
1934
+ const existingTargetState = state.targets[targetName];
1935
+ if (existingTargetState) {
1936
+ log(options, `[info] Spec 目标已启用,执行一致性修复: ${targetName}`);
1937
+ }
1938
+ const targetState = existingTargetState || {
1660
1939
  enabledAt: nowISO(),
1661
1940
  consumers: {},
1662
1941
  };
1663
1942
 
1664
1943
  for (const destination of destinations) {
1944
+ const existingConsumerState =
1945
+ targetState.consumers && targetState.consumers[destination.id] ? targetState.consumers[destination.id] : null;
1665
1946
  const consumerState = {
1666
1947
  skills: [],
1667
1948
  };
1949
+ const existingSkills = new Map();
1950
+ if (existingConsumerState && Array.isArray(existingConsumerState.skills)) {
1951
+ for (const skill of existingConsumerState.skills) {
1952
+ if (skill && typeof skill.name === "string") {
1953
+ existingSkills.set(skill.name, skill);
1954
+ }
1955
+ }
1956
+ }
1668
1957
 
1669
1958
  for (const skillName of SPEC_SKILL_NAMES) {
1670
1959
  const srcDir = path.join(BUNDLED_SPEC_DIR, "skills", skillName);
1671
1960
  const destDir = path.join(destination.skillsRoot, skillName);
1961
+ const existingSkillState = existingSkills.get(skillName);
1962
+ const mode = normalizeSpecAssetMode(existingSkillState);
1672
1963
  const exists = fs.existsSync(destDir);
1964
+ const equal = exists ? areDirectoriesEqual(srcDir, destDir) : false;
1965
+
1966
+ if (mode === "kept" && exists) {
1967
+ consumerState.skills.push({
1968
+ name: skillName,
1969
+ destPath: destDir,
1970
+ backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
1971
+ mode: "kept",
1972
+ });
1973
+ continue;
1974
+ }
1975
+
1976
+ if (exists && equal) {
1977
+ consumerState.skills.push({
1978
+ name: skillName,
1979
+ destPath: destDir,
1980
+ backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
1981
+ mode: existingSkillState && existingSkillState.mode ? existingSkillState.mode : "kept",
1982
+ });
1983
+ continue;
1984
+ }
1985
+
1673
1986
  let action = exists ? "backup" : "";
1674
1987
  if (exists && prompter) {
1675
1988
  action = await prompter.resolveConflict({
@@ -1677,6 +1990,8 @@ async function enableSpecTarget(targetName, state, timestamp, options, prompter)
1677
1990
  label: `Spec Skill ${destination.id}/${skillName}`,
1678
1991
  path: destDir,
1679
1992
  });
1993
+ } else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
1994
+ action = "backup";
1680
1995
  }
1681
1996
 
1682
1997
  if (action === "keep") {
@@ -1684,13 +1999,13 @@ async function enableSpecTarget(targetName, state, timestamp, options, prompter)
1684
1999
  consumerState.skills.push({
1685
2000
  name: skillName,
1686
2001
  destPath: destDir,
1687
- backupPath: "",
2002
+ backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
1688
2003
  mode: "kept",
1689
2004
  });
1690
2005
  continue;
1691
2006
  }
1692
2007
 
1693
- let backupPath = "";
2008
+ let backupPath = existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "";
1694
2009
  if (exists && action !== "remove") {
1695
2010
  backupPath = backupDirSnapshot(
1696
2011
  destDir,
@@ -1742,14 +2057,28 @@ function disableSpecTarget(targetName, state, options) {
1742
2057
  }
1743
2058
 
1744
2059
  function evaluateSpecState() {
2060
+ const statePath = getSpecStatePath();
2061
+ const hasStateFile = fs.existsSync(statePath);
1745
2062
  const { state } = readSpecState();
1746
2063
  const targetNames = Object.keys(state.targets || {});
1747
2064
  if (targetNames.length === 0) {
2065
+ const specHome = getSpecHomeDir();
2066
+ const orphanIssues = collectSpecOrphanIssues(specHome, hasStateFile);
2067
+ if (orphanIssues.length > 0) {
2068
+ return {
2069
+ state: "broken",
2070
+ targets: [],
2071
+ assets: state.assets || {},
2072
+ specHome,
2073
+ issues: orphanIssues,
2074
+ };
2075
+ }
1748
2076
  return {
1749
2077
  state: "missing",
1750
2078
  targets: [],
1751
2079
  assets: state.assets || {},
1752
- specHome: getSpecHomeDir(),
2080
+ specHome,
2081
+ issues: [],
1753
2082
  };
1754
2083
  }
1755
2084
 
@@ -1765,10 +2094,23 @@ function evaluateSpecState() {
1765
2094
  }
1766
2095
  }
1767
2096
 
1768
- for (const assetName of ["templates", "references"]) {
2097
+ const specHome = getSpecHomeDir();
2098
+ const assetRequirements = {
2099
+ templates: { dir: path.join(specHome, "templates"), files: SPEC_TEMPLATE_REQUIRED_FILES },
2100
+ references: { dir: path.join(specHome, "references"), files: SPEC_REFERENCE_REQUIRED_FILES },
2101
+ profiles: { dir: path.join(specHome, "profiles"), files: SPEC_PROFILE_REQUIRED_FILES },
2102
+ };
2103
+
2104
+ for (const assetName of ["templates", "references", "profiles"]) {
1769
2105
  const asset = state.assets[assetName];
1770
2106
  if (!asset || !asset.destPath || !fs.existsSync(asset.destPath)) {
1771
2107
  issues.push(`Missing spec asset: ${assetName}`);
2108
+ continue;
2109
+ }
2110
+ const requirement = assetRequirements[assetName];
2111
+ const missing = listMissingFiles(requirement.dir, requirement.files);
2112
+ for (const rel of missing) {
2113
+ issues.push(`Missing spec asset file: ${assetName}/${rel}`);
1772
2114
  }
1773
2115
  }
1774
2116
 
@@ -1777,7 +2119,7 @@ function evaluateSpecState() {
1777
2119
  targets: targetNames,
1778
2120
  issues,
1779
2121
  assets: state.assets || {},
1780
- specHome: getSpecHomeDir(),
2122
+ specHome,
1781
2123
  };
1782
2124
  }
1783
2125
 
@@ -1808,6 +2150,337 @@ function commandSpecStatus(options) {
1808
2150
  setQuietStatusExitCode(summary.state);
1809
2151
  }
1810
2152
 
2153
+ function stripUtf8Bom(text) {
2154
+ if (!text) return "";
2155
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
2156
+ }
2157
+
2158
+ function parseCsvLine(line) {
2159
+ const cells = [];
2160
+ let current = "";
2161
+ let inQuotes = false;
2162
+
2163
+ for (let i = 0; i < line.length; i++) {
2164
+ const ch = line[i];
2165
+ if (ch === "\"") {
2166
+ if (inQuotes && line[i + 1] === "\"") {
2167
+ current += "\"";
2168
+ i += 1;
2169
+ continue;
2170
+ }
2171
+ inQuotes = !inQuotes;
2172
+ continue;
2173
+ }
2174
+ if (ch === "," && !inQuotes) {
2175
+ cells.push(current);
2176
+ current = "";
2177
+ continue;
2178
+ }
2179
+ current += ch;
2180
+ }
2181
+ cells.push(current);
2182
+ return cells;
2183
+ }
2184
+
2185
+ function analyzeIssuesCsv(issuesPath) {
2186
+ if (!fs.existsSync(issuesPath)) {
2187
+ return { status: "missing", issues: ["Missing issues.csv"], stats: { total: 0, inProgress: 0 } };
2188
+ }
2189
+
2190
+ const raw = stripUtf8Bom(fs.readFileSync(issuesPath, "utf8"));
2191
+ const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
2192
+ if (lines.length === 0) {
2193
+ return { status: "broken", issues: ["issues.csv is empty"], stats: { total: 0, inProgress: 0 } };
2194
+ }
2195
+
2196
+ const header = parseCsvLine(lines[0]).map((cell) => cell.trim());
2197
+ const statusIndex = header.findIndex((cell) => cell === "状态" || cell.includes("(状态)") || /状态/.test(cell));
2198
+ if (statusIndex < 0) {
2199
+ return { status: "broken", issues: ["issues.csv header missing 状态 column"], stats: { total: Math.max(0, lines.length - 1), inProgress: 0 } };
2200
+ }
2201
+
2202
+ const allowedStates = new Set(["未开始", "进行中", "已完成"]);
2203
+ let total = 0;
2204
+ let inProgress = 0;
2205
+ const issues = [];
2206
+
2207
+ for (let i = 1; i < lines.length; i++) {
2208
+ const row = parseCsvLine(lines[i]);
2209
+ const isEmpty = row.every((cell) => String(cell || "").trim() === "");
2210
+ if (isEmpty) {
2211
+ continue;
2212
+ }
2213
+ total += 1;
2214
+ const state = String(row[statusIndex] || "").trim();
2215
+ if (!allowedStates.has(state)) {
2216
+ issues.push(`Invalid 状态 at row ${i + 1}: ${state || "(empty)"}`);
2217
+ continue;
2218
+ }
2219
+ if (state === "进行中") {
2220
+ inProgress += 1;
2221
+ }
2222
+ }
2223
+
2224
+ if (inProgress > 1) {
2225
+ issues.push(`Multiple tasks in 进行中: ${inProgress}`);
2226
+ }
2227
+
2228
+ return {
2229
+ status: issues.length > 0 ? "broken" : "ok",
2230
+ issues,
2231
+ stats: { total, inProgress },
2232
+ };
2233
+ }
2234
+
2235
+ function checkSpecProjectIntegrity(workspaceRoot) {
2236
+ const issues = [];
2237
+ const issuesPath = path.join(workspaceRoot, "issues.csv");
2238
+ const issuesResult = analyzeIssuesCsv(issuesPath);
2239
+
2240
+ const specDir = path.join(workspaceRoot, ".ling", "spec");
2241
+ const templatesDir = path.join(specDir, "templates");
2242
+ const referencesDir = path.join(specDir, "references");
2243
+ const profilesDir = path.join(specDir, "profiles");
2244
+
2245
+ const hasSpecDir = fs.existsSync(specDir);
2246
+ const globalSpecSummary = !hasSpecDir && issuesResult.status !== "missing" ? evaluateSpecState() : null;
2247
+ const canFallbackToGlobalSpec = Boolean(globalSpecSummary && globalSpecSummary.state === "installed");
2248
+ if (!hasSpecDir) {
2249
+ if (issuesResult.status !== "missing") {
2250
+ if (!canFallbackToGlobalSpec) {
2251
+ issues.push("Missing .ling/spec directory (run: ling spec init)");
2252
+ if (globalSpecSummary && globalSpecSummary.state === "missing") {
2253
+ issues.push("Global Spec is not enabled (run: ling spec enable)");
2254
+ } else if (globalSpecSummary && globalSpecSummary.state === "broken") {
2255
+ issues.push("Global Spec is broken (run: ling spec enable)");
2256
+ for (const issue of globalSpecSummary.issues || []) {
2257
+ issues.push(`Global Spec issue: ${issue}`);
2258
+ }
2259
+ }
2260
+ }
2261
+ }
2262
+ } else {
2263
+ if (issuesResult.status === "missing") {
2264
+ issues.push("Missing issues.csv (run: ling spec init)");
2265
+ }
2266
+ for (const rel of SPEC_TEMPLATE_REQUIRED_FILES) {
2267
+ const filePath = path.join(templatesDir, rel);
2268
+ if (!fs.existsSync(filePath)) {
2269
+ issues.push(`Missing spec template: .ling/spec/templates/${rel}`);
2270
+ }
2271
+ }
2272
+ for (const rel of SPEC_REFERENCE_REQUIRED_FILES) {
2273
+ const filePath = path.join(referencesDir, rel);
2274
+ if (!fs.existsSync(filePath)) {
2275
+ issues.push(`Missing spec reference: .ling/spec/references/${rel}`);
2276
+ }
2277
+ }
2278
+ for (const rel of SPEC_PROFILE_REQUIRED_FILES) {
2279
+ const filePath = path.join(profilesDir, ...rel.split("/"));
2280
+ if (!fs.existsSync(filePath)) {
2281
+ issues.push(`Missing spec profile: .ling/spec/profiles/${rel}`);
2282
+ }
2283
+ }
2284
+ }
2285
+
2286
+ if (issuesResult.status === "broken") {
2287
+ issues.push(...issuesResult.issues);
2288
+ }
2289
+
2290
+ const hasAnySpecSignal = hasSpecDir || issuesResult.status !== "missing";
2291
+ if (!hasAnySpecSignal) {
2292
+ return { status: "missing", issues: [], stats: { total: 0, inProgress: 0 } };
2293
+ }
2294
+
2295
+ return {
2296
+ status: issues.length > 0 ? "broken" : "ok",
2297
+ issues,
2298
+ stats: issuesResult.stats,
2299
+ };
2300
+ }
2301
+
2302
+ function resolveWorkspaceSpecBackupRoot(workspaceRoot, timestamp) {
2303
+ return path.join(workspaceRoot, ".ling", "backups", "spec", timestamp, "before");
2304
+ }
2305
+
2306
+ async function commandSpecInit(options) {
2307
+ const workspaceRoot = options.path
2308
+ ? resolveWorkspaceRoot(options.path)
2309
+ : (options.specWorkspace ? getSpecWorkspaceDir() : resolveWorkspaceRoot());
2310
+ const csvOnly = Boolean(options.csvOnly);
2311
+ const prompter = createConflictPrompter(options);
2312
+ const timestamp = nowISO().replace(/[:.]/g, "-");
2313
+ const backupRoot = resolveWorkspaceSpecBackupRoot(workspaceRoot, timestamp);
2314
+
2315
+ const specDir = path.join(workspaceRoot, ".ling", "spec");
2316
+ let specSourceDir = BUNDLED_SPEC_DIR;
2317
+ let cleanupSpec = null;
2318
+ if (options.branch) {
2319
+ const remote = cloneBranchSpecDir(options.branch, {
2320
+ quiet: options.quiet,
2321
+ logger: log.bind(null, options),
2322
+ });
2323
+ specSourceDir = remote.specDir;
2324
+ cleanupSpec = remote.cleanup;
2325
+ }
2326
+ const assets = {
2327
+ templates: {
2328
+ sourceDir: path.join(specSourceDir, "templates"),
2329
+ destDir: path.join(specDir, "templates"),
2330
+ },
2331
+ references: {
2332
+ sourceDir: path.join(specSourceDir, "references"),
2333
+ destDir: path.join(specDir, "references"),
2334
+ },
2335
+ profiles: {
2336
+ sourceDir: path.join(specSourceDir, "profiles"),
2337
+ destDir: path.join(specDir, "profiles"),
2338
+ },
2339
+ };
2340
+
2341
+ try {
2342
+ fs.mkdirSync(workspaceRoot, { recursive: true });
2343
+
2344
+ if (!csvOnly) {
2345
+ for (const [assetName, config] of Object.entries(assets)) {
2346
+ const exists = fs.existsSync(config.destDir);
2347
+ if (exists && areDirectoriesEqual(config.sourceDir, config.destDir)) {
2348
+ log(options, `[skip] Spec ${assetName} 已最新,无需覆盖: ${config.destDir}`);
2349
+ continue;
2350
+ }
2351
+
2352
+ let action = exists ? "backup" : "";
2353
+ if (exists && prompter) {
2354
+ action = await prompter.resolveConflict({
2355
+ category: "spec:project:assets",
2356
+ label: `Spec ${assetName}`,
2357
+ path: config.destDir,
2358
+ });
2359
+ } else if (exists && !prompter && (options.force || options.nonInteractive || !process.stdin.isTTY)) {
2360
+ action = "backup";
2361
+ }
2362
+
2363
+ if (action === "keep") {
2364
+ log(options, `[skip] 已保留 Spec ${assetName} 资产,不覆盖: ${config.destDir}`);
2365
+ continue;
2366
+ }
2367
+
2368
+ if (exists && action !== "remove") {
2369
+ backupDirSnapshot(config.destDir, path.join(backupRoot, "assets", assetName), options, `Spec ${assetName}`);
2370
+ } else if (exists && action === "remove") {
2371
+ removeDirIfExists(config.destDir, options, `Spec ${assetName}`);
2372
+ }
2373
+ applyDirSnapshot(config.sourceDir, config.destDir, options, `Spec ${assetName}`);
2374
+ }
2375
+ }
2376
+
2377
+ const issuesPath = path.join(workspaceRoot, "issues.csv");
2378
+ let issuesTemplatePath = path.join(specSourceDir, "templates", "issues.template.csv");
2379
+ if (csvOnly && !options.branch) {
2380
+ const globalTemplatePath = path.join(getSpecHomeDir(), "templates", "issues.template.csv");
2381
+ if (fs.existsSync(globalTemplatePath)) {
2382
+ issuesTemplatePath = globalTemplatePath;
2383
+ } else if (!options.quiet) {
2384
+ log(options, "[warn] 未检测到全局 Spec templates,已使用内置模板生成 issues.csv。可执行: ling spec enable");
2385
+ }
2386
+ }
2387
+ const issuesTemplate = stripUtf8Bom(fs.readFileSync(issuesTemplatePath, "utf8"));
2388
+ const hasIssues = fs.existsSync(issuesPath);
2389
+ if (hasIssues) {
2390
+ let action = "backup";
2391
+ if (prompter) {
2392
+ action = await prompter.resolveConflict({
2393
+ category: "spec:project:file",
2394
+ label: "issues.csv",
2395
+ path: issuesPath,
2396
+ });
2397
+ }
2398
+ if (action === "keep") {
2399
+ log(options, "[skip] 已保留现有 issues.csv,不覆盖");
2400
+ } else {
2401
+ if (action !== "remove") {
2402
+ backupFileSnapshot(issuesPath, path.join(backupRoot, "issues.csv"), options, "issues.csv");
2403
+ } else if (!options.dryRun) {
2404
+ fs.rmSync(issuesPath, { force: true });
2405
+ }
2406
+ atomicWriteFile(issuesPath, `${issuesTemplate.trimEnd()}\n`, options, "issues.csv");
2407
+ log(options, options.dryRun ? `[dry-run] 将写入任务跟踪文件: ${issuesPath}` : `[ok] 已写入任务跟踪文件: ${issuesPath}`);
2408
+ }
2409
+ } else {
2410
+ atomicWriteFile(issuesPath, `${issuesTemplate.trimEnd()}\n`, options, "issues.csv");
2411
+ log(options, options.dryRun ? `[dry-run] 将创建任务跟踪文件: ${issuesPath}` : `[ok] 已创建任务跟踪文件: ${issuesPath}`);
2412
+ }
2413
+
2414
+ const docsReviewsDir = path.join(workspaceRoot, "docs", "reviews");
2415
+ const docsHandoffDir = path.join(workspaceRoot, "docs", "handoff");
2416
+ if (options.dryRun) {
2417
+ log(options, `[dry-run] 将确保目录存在: ${docsReviewsDir}`);
2418
+ log(options, `[dry-run] 将确保目录存在: ${docsHandoffDir}`);
2419
+ } else {
2420
+ fs.mkdirSync(docsReviewsDir, { recursive: true });
2421
+ fs.mkdirSync(docsHandoffDir, { recursive: true });
2422
+ }
2423
+
2424
+ const requestedTargets = normalizeTargets(options.targets);
2425
+ const isSpecWorkspaceMode = Boolean(!options.path && options.specWorkspace);
2426
+ const shouldInitTargets = isSpecWorkspaceMode ? true : requestedTargets.length > 0;
2427
+ const targets = shouldInitTargets ? (requestedTargets.length > 0 ? requestedTargets : [...SUPPORTED_TARGETS]) : [];
2428
+ if (targets.length > 0) {
2429
+ await initTargets(workspaceRoot, targets, options, prompter);
2430
+ }
2431
+
2432
+ log(options, `[ok] Spec 初始化完成: ${workspaceRoot}`);
2433
+ } finally {
2434
+ if (cleanupSpec) cleanupSpec();
2435
+ if (prompter) prompter.close();
2436
+ }
2437
+ }
2438
+
2439
+ function commandSpecDoctor(options) {
2440
+ const workspaceRoot = options.path
2441
+ ? resolveWorkspaceRoot(options.path)
2442
+ : (options.specWorkspace ? getSpecWorkspaceDir() : resolveWorkspaceRoot());
2443
+ const result = checkSpecProjectIntegrity(workspaceRoot);
2444
+ const state = result.status === "ok" ? "installed" : result.status;
2445
+
2446
+ if (options.quiet) {
2447
+ console.log(state);
2448
+ setQuietStatusExitCode(state);
2449
+ return;
2450
+ }
2451
+
2452
+ if (state === "missing") {
2453
+ console.log("[warn] 未检测到 Spec 项目资产");
2454
+ console.log(` 工作区: ${workspaceRoot}`);
2455
+ setQuietStatusExitCode("missing");
2456
+ return;
2457
+ }
2458
+
2459
+ console.log(state === "installed" ? "[ok] Spec 项目资产状态正常" : "[warn] Spec 项目资产存在问题");
2460
+ console.log(` 工作区: ${workspaceRoot}`);
2461
+ const specDir = path.join(workspaceRoot, ".ling", "spec");
2462
+ const hasSpecDir = fs.existsSync(specDir);
2463
+ const issuesPath = path.join(workspaceRoot, "issues.csv");
2464
+ const hasIssues = fs.existsSync(issuesPath);
2465
+ if (hasSpecDir) {
2466
+ console.log(" 模式: project");
2467
+ console.log(` Spec 根目录: ${specDir}`);
2468
+ } else if (hasIssues) {
2469
+ const globalSpecSummary = evaluateSpecState();
2470
+ const fallback = globalSpecSummary.state === "installed";
2471
+ console.log(` 模式: csv-only${fallback ? " (global fallback)" : ""}`);
2472
+ if (fallback) {
2473
+ console.log(` Spec 根目录: ${getSpecHomeDir()}`);
2474
+ }
2475
+ }
2476
+ console.log(` 任务数: ${result.stats.total}`);
2477
+ console.log(` 进行中: ${result.stats.inProgress}`);
2478
+ for (const issue of result.issues || []) {
2479
+ console.log(` Issue: ${issue}`);
2480
+ }
2481
+ setQuietStatusExitCode(state);
2482
+ }
2483
+
1811
2484
  async function commandSpecEnable(options) {
1812
2485
  ensureBundledSpecResources();
1813
2486
  const targets = resolveTargetsForSpec(options);
@@ -1844,6 +2517,7 @@ function commandSpecDisable(options) {
1844
2517
  if (remainingTargets.length === 0) {
1845
2518
  restoreSpecAsset(state.assets.templates, options, "Spec templates");
1846
2519
  restoreSpecAsset(state.assets.references, options, "Spec references");
2520
+ restoreSpecAsset(state.assets.profiles, options, "Spec profiles");
1847
2521
  state.assets = {};
1848
2522
  }
1849
2523
 
@@ -1851,6 +2525,14 @@ function commandSpecDisable(options) {
1851
2525
  if (!options.dryRun) {
1852
2526
  if (remainingTargets.length === 0) {
1853
2527
  removeSpecStateFile();
2528
+ const specHome = getSpecHomeDir();
2529
+ try {
2530
+ if (fs.existsSync(specHome) && fs.readdirSync(specHome).length === 0) {
2531
+ fs.rmdirSync(specHome);
2532
+ }
2533
+ } catch (err) {
2534
+ log(options, `[warn] 无法清理 Spec 根目录: ${err.message}`);
2535
+ }
1854
2536
  } else {
1855
2537
  writeSpecState(statePath, state);
1856
2538
  }
@@ -1870,87 +2552,100 @@ async function commandSpec(options) {
1870
2552
  if (subcommand === "disable") {
1871
2553
  return commandSpecDisable(options);
1872
2554
  }
2555
+ if (subcommand === "init") {
2556
+ return await commandSpecInit(options);
2557
+ }
2558
+ if (subcommand === "doctor") {
2559
+ return commandSpecDoctor(options);
2560
+ }
1873
2561
  throw new Error(`未知 spec 子命令: ${subcommand}`);
1874
2562
  }
1875
2563
 
1876
- async function commandInit(options) {
1877
- const workspaceRoot = resolveWorkspaceRoot(options.path);
1878
- const targets = await resolveTargetsForInit(options);
1879
- const prompter = createConflictPrompter(options);
2564
+ async function initTargets(workspaceRoot, targets, options, prompter) {
2565
+ for (const group of groupTargetsByInstallSurface(targets)) {
2566
+ const runOptions = { ...options };
2567
+ const conflicts = [];
1880
2568
 
1881
- try {
1882
- for (const target of targets) {
1883
- const runOptions = { ...options };
1884
- const conflicts = [];
2569
+ if (group.installSurface === ".agent") {
2570
+ const agentDir = path.join(workspaceRoot, ".agent");
2571
+ if (fs.existsSync(agentDir)) {
2572
+ conflicts.push({
2573
+ category: "project:shared-agent",
2574
+ label: ".agent",
2575
+ path: agentDir,
2576
+ target: group.logicalTargets.join(","),
2577
+ });
2578
+ }
2579
+ }
1885
2580
 
1886
- if (target === "gemini") {
1887
- const agentDir = path.join(workspaceRoot, ".agent");
1888
- if (fs.existsSync(agentDir)) {
2581
+ if (group.adapterTarget === "codex") {
2582
+ const managedDir = path.join(workspaceRoot, ".agents");
2583
+ const legacyDir = path.join(workspaceRoot, ".codex");
2584
+ if (fs.existsSync(managedDir) || fs.existsSync(legacyDir)) {
2585
+ if (fs.existsSync(managedDir)) {
1889
2586
  conflicts.push({
1890
- category: "project:gemini",
1891
- label: ".agent",
1892
- path: agentDir,
2587
+ category: "project:codex",
2588
+ label: ".agents",
2589
+ path: managedDir,
1893
2590
  target,
1894
2591
  });
1895
2592
  }
1896
- }
1897
-
1898
- if (target === "codex") {
1899
- const managedDir = path.join(workspaceRoot, ".agents");
1900
- const legacyDir = path.join(workspaceRoot, ".codex");
1901
- if (fs.existsSync(managedDir) || fs.existsSync(legacyDir)) {
1902
- if (fs.existsSync(managedDir)) {
1903
- conflicts.push({
1904
- category: "project:codex",
1905
- label: ".agents",
1906
- path: managedDir,
1907
- target,
1908
- });
1909
- }
1910
- if (fs.existsSync(legacyDir)) {
1911
- conflicts.push({
1912
- category: "project:codex",
1913
- label: ".codex",
1914
- path: legacyDir,
1915
- target,
1916
- });
1917
- }
2593
+ if (fs.existsSync(legacyDir)) {
2594
+ conflicts.push({
2595
+ category: "project:codex",
2596
+ label: ".codex",
2597
+ path: legacyDir,
2598
+ target,
2599
+ });
1918
2600
  }
1919
2601
  }
2602
+ }
1920
2603
 
1921
- if (conflicts.length > 0) {
1922
- if (!prompter && !runOptions.force) {
1923
- throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。");
1924
- }
2604
+ if (conflicts.length > 0) {
2605
+ if (!prompter && !runOptions.force) {
2606
+ throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。");
2607
+ }
1925
2608
 
1926
- const timestamp = nowISO().replace(/[:.]/g, "-");
1927
- let shouldSkip = false;
2609
+ const timestamp = nowISO().replace(/[:.]/g, "-");
2610
+ let shouldSkip = false;
1928
2611
 
1929
- for (const conflict of conflicts) {
1930
- const action = prompter ? await prompter.resolveConflict(conflict) : "backup";
1931
- if (action === "keep") {
1932
- log(options, `[skip] 已保留现有资产,跳过初始化: ${conflict.label}`);
1933
- shouldSkip = true;
1934
- break;
1935
- }
1936
- if (action === "backup") {
1937
- const backupRootName = conflict.label === ".agent" ? ".agent-backup" : ".agents-backup";
1938
- backupWorkspaceDir(workspaceRoot, conflict.path, backupRootName, timestamp, options, `工作区资产 ${conflict.label}`);
1939
- }
1940
- // remove/backup 都需要强制覆盖才能继续
1941
- runOptions.force = true;
2612
+ for (const conflict of conflicts) {
2613
+ const action = prompter ? await prompter.resolveConflict(conflict) : "backup";
2614
+ if (action === "keep") {
2615
+ log(options, `[skip] 已保留现有资产,跳过初始化: ${conflict.label}`);
2616
+ shouldSkip = true;
2617
+ break;
1942
2618
  }
1943
-
1944
- if (shouldSkip) {
1945
- continue;
2619
+ if (action === "backup") {
2620
+ const backupRootName = conflict.label === ".agent" ? ".agent-backup" : ".agents-backup";
2621
+ backupWorkspaceDir(workspaceRoot, conflict.path, backupRootName, timestamp, options, `工作区资产 ${conflict.label}`);
1946
2622
  }
2623
+ // remove/backup 都需要强制覆盖才能继续
2624
+ runOptions.force = true;
1947
2625
  }
1948
2626
 
1949
- const adapter = createAdapter(target, workspaceRoot, runOptions);
1950
- log(options, `[sync] 正在初始化目标 [${target}] ...`);
1951
- adapter.install(BUNDLED_AGENT_DIR);
2627
+ if (shouldSkip) {
2628
+ continue;
2629
+ }
2630
+ }
2631
+
2632
+ const adapter = createAdapter(group.adapterTarget, workspaceRoot, runOptions);
2633
+ log(options, `[sync] 正在初始化目标 [${group.logicalTargets.join(", ")}] ...`);
2634
+ adapter.install(BUNDLED_AGENT_DIR);
2635
+ recordWorkspaceInstallTargets(workspaceRoot, group.logicalTargets, runOptions);
2636
+ for (const target of group.logicalTargets) {
1952
2637
  registerWorkspaceTarget(workspaceRoot, target, runOptions);
1953
2638
  }
2639
+ }
2640
+ }
2641
+
2642
+ async function commandInit(options) {
2643
+ const workspaceRoot = resolveWorkspaceRoot(options.path);
2644
+ const targets = await resolveTargetsForInit(options);
2645
+ const prompter = createConflictPrompter(options);
2646
+
2647
+ try {
2648
+ await initTargets(workspaceRoot, targets, options, prompter);
1954
2649
  } finally {
1955
2650
  if (prompter) prompter.close();
1956
2651
  }
@@ -2025,71 +2720,73 @@ async function commandUpdate(options) {
2025
2720
 
2026
2721
  let updatedAny = false;
2027
2722
  try {
2028
- for (const target of targets) {
2029
- if (!isTargetInstalled(workspaceRoot, target) && options.targets.length > 0) {
2030
- throw new Error(`目标未安装: ${target}`);
2031
- }
2032
- if (!isTargetInstalled(workspaceRoot, target)) {
2033
- log(options, `[skip] 目标未安装,跳过: ${target}`);
2034
- continue;
2035
- }
2723
+ for (const group of groupTargetsByInstallSurface(targets)) {
2724
+ if (!isTargetInstalled(workspaceRoot, group.adapterTarget) && options.targets.length > 0) {
2725
+ throw new Error(`目标未安装: ${group.logicalTargets.join(", ")}`);
2726
+ }
2727
+ if (!isTargetInstalled(workspaceRoot, group.adapterTarget)) {
2728
+ log(options, `[skip] 目标未安装,跳过: ${group.logicalTargets.join(", ")}`);
2729
+ continue;
2730
+ }
2036
2731
 
2037
- const runOptions = { ...options, force: true };
2732
+ const runOptions = { ...options, force: true };
2733
+ const timestamp = nowISO().replace(/[:.]/g, "-");
2038
2734
 
2039
- const timestamp = nowISO().replace(/[:.]/g, "-");
2040
- if (target === "gemini") {
2041
- const agentDir = path.join(workspaceRoot, ".agent");
2042
- if (fs.existsSync(agentDir) && !areDirectoriesEqual(BUNDLED_AGENT_DIR, agentDir)) {
2043
- let action = "backup";
2044
- if (prompter) {
2045
- action = await prompter.resolveConflict({
2046
- category: "project:gemini",
2047
- label: ".agent",
2048
- path: agentDir,
2049
- target,
2050
- });
2051
- }
2052
- if (action === "keep") {
2053
- log(options, "[skip] 已保留现有资产,跳过更新: .agent");
2054
- continue;
2055
- }
2056
- if (action === "backup") {
2057
- backupWorkspaceDir(workspaceRoot, agentDir, ".agent-backup", timestamp, options, "工作区资产 .agent");
2735
+ if (group.installSurface === ".agent") {
2736
+ const agentDir = path.join(workspaceRoot, ".agent");
2737
+ if (fs.existsSync(agentDir) && !areDirectoriesEqual(BUNDLED_AGENT_DIR, agentDir)) {
2738
+ let action = "backup";
2739
+ if (prompter) {
2740
+ action = await prompter.resolveConflict({
2741
+ category: "project:shared-agent",
2742
+ label: ".agent",
2743
+ path: agentDir,
2744
+ target: group.logicalTargets.join(","),
2745
+ });
2746
+ }
2747
+ if (action === "keep") {
2748
+ log(options, "[skip] 已保留现有资产,跳过更新: .agent");
2749
+ continue;
2750
+ }
2751
+ if (action === "backup") {
2752
+ backupWorkspaceDir(workspaceRoot, agentDir, ".agent-backup", timestamp, options, "工作区资产 .agent");
2753
+ }
2058
2754
  }
2059
2755
  }
2060
- }
2061
2756
 
2062
- if (target === "codex") {
2063
- const managedDir = path.join(workspaceRoot, ".agents");
2064
- const legacyDir = path.join(workspaceRoot, ".codex");
2065
- const activeDir = fs.existsSync(managedDir) ? managedDir : legacyDir;
2066
- const conflict = evaluateCodexUpdateConflict(activeDir);
2067
- if (conflict.hasConflict) {
2068
- let action = "backup";
2069
- if (prompter) {
2070
- action = await prompter.resolveConflict({
2071
- category: "project:codex",
2072
- label: fs.existsSync(managedDir) ? ".agents" : ".codex",
2073
- path: activeDir,
2074
- target,
2075
- });
2076
- }
2077
- if (action === "keep") {
2078
- log(options, `[skip] 已保留现有资产,跳过更新: ${path.basename(activeDir)}`);
2079
- continue;
2080
- }
2081
- if (action === "backup") {
2082
- const backupRootName = ".agents-backup";
2083
- backupWorkspaceDir(workspaceRoot, activeDir, backupRootName, timestamp, options, `工作区资产 ${path.basename(activeDir)}`);
2757
+ if (group.adapterTarget === "codex") {
2758
+ const managedDir = path.join(workspaceRoot, ".agents");
2759
+ const legacyDir = path.join(workspaceRoot, ".codex");
2760
+ const activeDir = fs.existsSync(managedDir) ? managedDir : legacyDir;
2761
+ const conflict = evaluateCodexUpdateConflict(activeDir);
2762
+ if (conflict.hasConflict) {
2763
+ let action = "backup";
2764
+ if (prompter) {
2765
+ action = await prompter.resolveConflict({
2766
+ category: "project:codex",
2767
+ label: fs.existsSync(managedDir) ? ".agents" : ".codex",
2768
+ path: activeDir,
2769
+ target: group.logicalTargets.join(","),
2770
+ });
2771
+ }
2772
+ if (action === "keep") {
2773
+ log(options, `[skip] 已保留现有资产,跳过更新: ${path.basename(activeDir)}`);
2774
+ continue;
2775
+ }
2776
+ if (action === "backup") {
2777
+ backupWorkspaceDir(workspaceRoot, activeDir, ".agents-backup", timestamp, options, `工作区资产 ${path.basename(activeDir)}`);
2778
+ }
2084
2779
  }
2085
2780
  }
2086
- }
2087
2781
 
2088
- const adapter = createAdapter(target, workspaceRoot, runOptions);
2089
- log(options, `[sync] 更新 [${target}] ...`);
2090
- adapter.update(BUNDLED_AGENT_DIR);
2091
- registerWorkspaceTarget(workspaceRoot, target, runOptions);
2092
- updatedAny = true;
2782
+ const adapter = createAdapter(group.adapterTarget, workspaceRoot, runOptions);
2783
+ log(options, `[sync] 更新 [${group.logicalTargets.join(", ")}] ...`);
2784
+ adapter.update(BUNDLED_AGENT_DIR);
2785
+ recordWorkspaceInstallTargets(workspaceRoot, group.logicalTargets, runOptions);
2786
+ for (const target of group.logicalTargets) {
2787
+ registerWorkspaceTarget(workspaceRoot, target, runOptions);
2788
+ }
2789
+ updatedAny = true;
2093
2790
  }
2094
2791
  } finally {
2095
2792
  if (prompter) prompter.close();
@@ -2182,7 +2879,7 @@ async function commandUpdateAll(options) {
2182
2879
  const installedTargets = detectInstalledTargets(workspacePath);
2183
2880
  let targets = [];
2184
2881
  if (requestedTargets.length > 0) {
2185
- targets = installedTargets.filter((target) => requestedTargets.includes(target));
2882
+ targets = requestedTargets.filter((target) => isTargetInstalled(workspacePath, target));
2186
2883
  } else {
2187
2884
  targets = [...Object.keys(item.targets || {}), ...installedTargets];
2188
2885
  }
@@ -2198,9 +2895,9 @@ async function commandUpdateAll(options) {
2198
2895
  log(options, `[sync] [${i + 1}/${records.length}] 更新: ${workspacePath} [${targets.join(", ")}]`);
2199
2896
 
2200
2897
  const updatedTargets = [];
2201
- for (const target of targets) {
2202
- if (!isTargetInstalled(workspacePath, target)) {
2203
- log(options, `[skip] [${i + 1}/${records.length}] 目标未安装,跳过: ${target}`);
2898
+ for (const group of groupTargetsByInstallSurface(targets)) {
2899
+ if (!isTargetInstalled(workspacePath, group.adapterTarget)) {
2900
+ log(options, `[skip] [${i + 1}/${records.length}] 目标未安装,跳过: ${group.logicalTargets.join(", ")}`);
2204
2901
  continue;
2205
2902
  }
2206
2903
 
@@ -2214,19 +2911,19 @@ async function commandUpdateAll(options) {
2214
2911
 
2215
2912
  const timestampForBackup = nowISO().replace(/[:.]/g, "-");
2216
2913
 
2217
- if (target === "gemini") {
2914
+ if (group.installSurface === ".agent") {
2218
2915
  const agentDir = path.join(workspacePath, ".agent");
2219
2916
  if (fs.existsSync(agentDir) && !areDirectoriesEqual(BUNDLED_AGENT_DIR, agentDir)) {
2220
2917
  let action = "backup";
2221
2918
  if (prompter) {
2222
2919
  action = await prompter.resolveConflict({
2223
- category: "update-all:project:gemini",
2920
+ category: "update-all:project:shared-agent",
2224
2921
  label: `.agent (${workspacePath})`,
2225
2922
  path: agentDir,
2226
2923
  });
2227
2924
  }
2228
2925
  if (action === "keep") {
2229
- log(options, `[skip] [${i + 1}/${records.length}] 已保留现有资产,跳过更新: ${workspacePath} [gemini]`);
2926
+ log(options, `[skip] [${i + 1}/${records.length}] 已保留现有资产,跳过更新: ${workspacePath} [${group.logicalTargets.join(", ")}]`);
2230
2927
  continue;
2231
2928
  }
2232
2929
  if (action === "backup") {
@@ -2235,7 +2932,7 @@ async function commandUpdateAll(options) {
2235
2932
  }
2236
2933
  }
2237
2934
 
2238
- if (target === "codex") {
2935
+ if (group.adapterTarget === "codex") {
2239
2936
  const managedDir = path.join(workspacePath, ".agents");
2240
2937
  const legacyDir = path.join(workspacePath, ".codex");
2241
2938
  const activeDir = fs.existsSync(managedDir) ? managedDir : legacyDir;
@@ -2259,13 +2956,14 @@ async function commandUpdateAll(options) {
2259
2956
  }
2260
2957
  }
2261
2958
 
2262
- const adapter = createAdapter(target, workspacePath, runOptions);
2959
+ const adapter = createAdapter(group.adapterTarget, workspacePath, runOptions);
2263
2960
  adapter.update(BUNDLED_AGENT_DIR);
2264
- updatedTargets.push(target);
2961
+ recordWorkspaceInstallTargets(workspacePath, group.logicalTargets, runOptions);
2962
+ updatedTargets.push(...group.logicalTargets);
2265
2963
  } catch (err) {
2266
2964
  failed += 1;
2267
2965
  if (!options.quiet) {
2268
- console.error(`[error] 更新失败: ${workspacePath} [${target}]`);
2966
+ console.error(`[error] 更新失败: ${workspacePath} [${group.logicalTargets.join(", ")}]`);
2269
2967
  console.error(` ${err.message}`);
2270
2968
  }
2271
2969
  }
@@ -2385,6 +3083,22 @@ async function commandDoctor(options) {
2385
3083
  }
2386
3084
  }
2387
3085
 
3086
+ const specResult = checkSpecProjectIntegrity(workspaceRoot);
3087
+ if (specResult.status !== "missing") {
3088
+ out(`\n[SPEC] 检查 Spec 项目资产...`);
3089
+ if (specResult.status === "ok") {
3090
+ out(" [ok] 状态正常");
3091
+ out(` - 任务数: ${specResult.stats.total}, 进行中: ${specResult.stats.inProgress}`);
3092
+ } else {
3093
+ out(` [error] 状态: ${specResult.status}`);
3094
+ out(` - 任务数: ${specResult.stats.total}, 进行中: ${specResult.stats.inProgress}`);
3095
+ for (const issue of specResult.issues || []) {
3096
+ out(` - ${issue}`);
3097
+ }
3098
+ hasIssue = true;
3099
+ }
3100
+ }
3101
+
2388
3102
  if (hasIssue) {
2389
3103
  process.exitCode = 1;
2390
3104
  }
@@ -2554,6 +3268,25 @@ function countSkillsRecursive(dir) {
2554
3268
  return count;
2555
3269
  }
2556
3270
 
3271
+ function printSharedAgentTargetStatus(workspaceRoot, targetState, label) {
3272
+ const agentDir = path.join(workspaceRoot, ".agent");
3273
+ const agentsCount = countFilesIfExists(path.join(agentDir, "agents"), (name) => name.endsWith(".md"));
3274
+ const workflowsCount = countFilesIfExists(path.join(agentDir, "workflows"), (name) => name.endsWith(".md"));
3275
+ const skillsCount = countSkillsRecursive(path.join(agentDir, "skills"));
3276
+ console.log(`\n[${label}]`);
3277
+ console.log(` 状态: ${targetState.state}`);
3278
+ console.log(` 路径: ${agentDir}`);
3279
+ if (targetState.state === "installed") {
3280
+ console.log(` Agents: ${agentsCount}`);
3281
+ console.log(` Skills: ${skillsCount}`);
3282
+ console.log(` Workflows: ${workflowsCount}`);
3283
+ return;
3284
+ }
3285
+ for (const issue of targetState.integrity.issues || []) {
3286
+ console.log(` Issue: ${issue}`);
3287
+ }
3288
+ }
3289
+
2557
3290
  function commandStatus(options) {
2558
3291
  const workspaceRoot = resolveWorkspaceRoot(options.path);
2559
3292
  const summary = evaluateWorkspaceState(workspaceRoot, options);
@@ -2584,22 +3317,12 @@ function commandStatus(options) {
2584
3317
 
2585
3318
  const geminiState = summary.targets.find((item) => item.targetName === "gemini");
2586
3319
  if (geminiState) {
2587
- const agentDir = path.join(workspaceRoot, ".agent");
2588
- const agentsCount = countFilesIfExists(path.join(agentDir, "agents"), (name) => name.endsWith(".md"));
2589
- const workflowsCount = countFilesIfExists(path.join(agentDir, "workflows"), (name) => name.endsWith(".md"));
2590
- const skillsCount = countSkillsRecursive(path.join(agentDir, "skills"));
2591
- console.log("\n[gemini]");
2592
- console.log(` 状态: ${geminiState.state}`);
2593
- console.log(` 路径: ${agentDir}`);
2594
- if (geminiState.state === "installed") {
2595
- console.log(` Agents: ${agentsCount}`);
2596
- console.log(` Skills: ${skillsCount}`);
2597
- console.log(` Workflows: ${workflowsCount}`);
2598
- } else {
2599
- for (const issue of geminiState.integrity.issues || []) {
2600
- console.log(` Issue: ${issue}`);
2601
- }
2602
- }
3320
+ printSharedAgentTargetStatus(workspaceRoot, geminiState, "gemini");
3321
+ }
3322
+
3323
+ const antigravityState = summary.targets.find((item) => item.targetName === "antigravity");
3324
+ if (antigravityState) {
3325
+ printSharedAgentTargetStatus(workspaceRoot, antigravityState, "antigravity");
2603
3326
  }
2604
3327
 
2605
3328
  const codexState = summary.targets.find((item) => item.targetName === "codex");