@mison/ling 1.1.0 → 1.1.1

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");
@@ -55,6 +55,9 @@ const QUIET_STATUS_EXIT_CODES = {
55
55
  const SPEC_STATE_VERSION = 1;
56
56
  const SPEC_SKILL_NAMES = ["harness-engineering", "cybernetic-systems-engineering"];
57
57
  const VERSION_TAG_PREFIX = "ling-";
58
+ const SPEC_TEMPLATE_REQUIRED_FILES = ["issues.template.csv", "driver-prompt.md", "review-report.md", "phase-acceptance.md", "handoff.md"];
59
+ const SPEC_REFERENCE_REQUIRED_FILES = ["README.md", "harness-engineering-digest.md", "gda-framework.md", "cse-quickstart.md"];
60
+ const SPEC_PROFILE_REQUIRED_FILES = ["codex/AGENTS.spec.md", "codex/ling.spec.rules.md", "gemini/GEMINI.spec.md"];
58
61
 
59
62
  function nowISO() {
60
63
  return new Date().toISOString();
@@ -172,6 +175,8 @@ function printUsage() {
172
175
  console.log(` ${PRIMARY_CLI_NAME} spec enable [--target <name>|--targets <a,b>] [--quiet] [--dry-run]`);
173
176
  console.log(` ${PRIMARY_CLI_NAME} spec disable [--target <name>|--targets <a,b>] [--quiet] [--dry-run]`);
174
177
  console.log(` ${PRIMARY_CLI_NAME} spec status [--quiet]`);
178
+ console.log(` ${PRIMARY_CLI_NAME} spec init [--path <dir>] [--target <name>|--targets <a,b>] [--branch <name>] [--force] [--non-interactive] [--no-index] [--quiet] [--dry-run]`);
179
+ console.log(` ${PRIMARY_CLI_NAME} spec doctor [--path <dir>] [--quiet]`);
175
180
  console.log(` ${PRIMARY_CLI_NAME} exclude list [--quiet]`);
176
181
  console.log(` ${PRIMARY_CLI_NAME} exclude add --path <dir> [--dry-run] [--quiet]`);
177
182
  console.log(` ${PRIMARY_CLI_NAME} exclude remove --path <dir> [--dry-run] [--quiet]`);
@@ -290,6 +295,8 @@ const COMMAND_ALLOWED_FLAGS = {
290
295
  "spec:enable": ["--target", "--targets", "--quiet", "--dry-run"],
291
296
  "spec:disable": ["--target", "--targets", "--quiet", "--dry-run"],
292
297
  "spec:status": ["--quiet"],
298
+ "spec:init": ["--force", "--path", "--branch", "--target", "--targets", "--non-interactive", "--no-index", "--quiet", "--dry-run"],
299
+ "spec:doctor": ["--path", "--quiet"],
293
300
  "exclude:list": ["--quiet"],
294
301
  "exclude:add": ["--path", "--dry-run", "--quiet"],
295
302
  "exclude:remove": ["--path", "--dry-run", "--quiet"],
@@ -1447,6 +1454,10 @@ function getSpecHomeDir() {
1447
1454
  return path.join(resolveGlobalRootDir(), ".ling", "spec");
1448
1455
  }
1449
1456
 
1457
+ function getSpecWorkspaceDir() {
1458
+ return path.join(resolveGlobalRootDir(), ".ling", "spec-workspace");
1459
+ }
1460
+
1450
1461
  function getSpecStatePath() {
1451
1462
  return path.join(getSpecHomeDir(), "state.json");
1452
1463
  }
@@ -1524,6 +1535,46 @@ function ensureBundledSpecResources() {
1524
1535
  }
1525
1536
  }
1526
1537
 
1538
+ function listMissingFiles(rootDir, requiredRelPaths) {
1539
+ if (!fs.existsSync(rootDir)) {
1540
+ return requiredRelPaths.map((rel) => rel);
1541
+ }
1542
+ return requiredRelPaths.filter((rel) => !fs.existsSync(path.join(rootDir, ...rel.split("/"))));
1543
+ }
1544
+
1545
+ function collectSpecOrphanIssues(specHome, hasStateFile) {
1546
+ const issues = [];
1547
+ const templatesDir = path.join(specHome, "templates");
1548
+ const referencesDir = path.join(specHome, "references");
1549
+ const profilesDir = path.join(specHome, "profiles");
1550
+
1551
+ if (fs.existsSync(templatesDir)) issues.push("Detected spec templates directory");
1552
+ if (fs.existsSync(referencesDir)) issues.push("Detected spec references directory");
1553
+ if (fs.existsSync(profilesDir)) issues.push("Detected spec profiles directory");
1554
+
1555
+ for (const targetName of SUPPORTED_TARGETS) {
1556
+ for (const destination of getGlobalDestinations(targetName)) {
1557
+ for (const skillName of SPEC_SKILL_NAMES) {
1558
+ if (fs.existsSync(path.join(destination.skillsRoot, skillName, "SKILL.md"))) {
1559
+ issues.push(`Detected spec skill: ${destination.id}/${skillName}`);
1560
+ }
1561
+ }
1562
+ }
1563
+ }
1564
+
1565
+ if (issues.length === 0) {
1566
+ return [];
1567
+ }
1568
+
1569
+ if (!hasStateFile) {
1570
+ issues.unshift("Spec artifacts detected but state.json missing (run: ling spec enable to repair)");
1571
+ } else {
1572
+ issues.unshift("Spec artifacts detected but no targets enabled (run: ling spec enable to reconcile or clean manually)");
1573
+ }
1574
+
1575
+ return issues;
1576
+ }
1577
+
1527
1578
  function backupDirSnapshot(sourceDir, backupDir, options, label) {
1528
1579
  if (!fs.existsSync(sourceDir)) {
1529
1580
  return "";
@@ -1560,6 +1611,59 @@ function removeDirIfExists(targetDir, options, label) {
1560
1611
  log(options, `[clean] 已删除 ${label}: ${targetDir}`);
1561
1612
  }
1562
1613
 
1614
+ function atomicWriteFile(targetPath, content, options, label) {
1615
+ const targetDir = path.dirname(targetPath);
1616
+ if (options.dryRun) {
1617
+ log(options, `[dry-run] 将写入 ${label}: ${targetPath}`);
1618
+ return;
1619
+ }
1620
+ fs.mkdirSync(targetDir, { recursive: true });
1621
+ const tempPath = `${targetPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1622
+ fs.writeFileSync(tempPath, content, "utf8");
1623
+ try {
1624
+ if (fs.existsSync(targetPath)) {
1625
+ const backupPath = `${targetPath}.bak-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`;
1626
+ fs.renameSync(targetPath, backupPath);
1627
+ try {
1628
+ fs.renameSync(tempPath, targetPath);
1629
+ } catch (err) {
1630
+ try {
1631
+ fs.renameSync(backupPath, targetPath);
1632
+ } catch (restoreErr) {
1633
+ throw new Error(`临界失败: 无法将新版本写入目标且无法恢复旧版本。旧版本位于 ${backupPath}。错误: ${err.message}`);
1634
+ }
1635
+ throw err;
1636
+ }
1637
+ try {
1638
+ fs.rmSync(backupPath, { force: true });
1639
+ } catch (cleanupErr) {
1640
+ log(options, `[warn] 无法清理备份文件 ${backupPath}: ${cleanupErr.message}`);
1641
+ }
1642
+ return;
1643
+ }
1644
+ fs.renameSync(tempPath, targetPath);
1645
+ } catch (err) {
1646
+ if (fs.existsSync(tempPath)) {
1647
+ fs.rmSync(tempPath, { force: true });
1648
+ }
1649
+ throw new Error(`原子写入失败: ${err.message}`);
1650
+ }
1651
+ }
1652
+
1653
+ function backupFileSnapshot(sourcePath, backupPath, options, label) {
1654
+ if (!fs.existsSync(sourcePath)) {
1655
+ return "";
1656
+ }
1657
+ if (options.dryRun) {
1658
+ log(options, `[dry-run] 将备份 ${label}: ${sourcePath} -> ${backupPath}`);
1659
+ return backupPath;
1660
+ }
1661
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
1662
+ fs.copyFileSync(sourcePath, backupPath);
1663
+ log(options, `[backup] 已备份 ${label}: ${sourcePath} -> ${backupPath}`);
1664
+ return backupPath;
1665
+ }
1666
+
1563
1667
  async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) {
1564
1668
  const specHome = getSpecHomeDir();
1565
1669
  const assets = {
@@ -1571,14 +1675,37 @@ async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) {
1571
1675
  sourceDir: path.join(BUNDLED_SPEC_DIR, "references"),
1572
1676
  destDir: path.join(specHome, "references"),
1573
1677
  },
1678
+ profiles: {
1679
+ sourceDir: path.join(BUNDLED_SPEC_DIR, "profiles"),
1680
+ destDir: path.join(specHome, "profiles"),
1681
+ },
1574
1682
  };
1575
1683
 
1576
1684
  for (const [assetName, config] of Object.entries(assets)) {
1577
- if (state.assets[assetName] && state.assets[assetName].installedAt) {
1685
+ const existingAssetState = state.assets[assetName];
1686
+ const hasState = existingAssetState && existingAssetState.installedAt;
1687
+ const exists = fs.existsSync(config.destDir);
1688
+ const mode = normalizeSpecAssetMode(existingAssetState);
1689
+
1690
+ if (mode === "kept" && exists) {
1691
+ continue;
1692
+ }
1693
+
1694
+ const equal = exists ? areDirectoriesEqual(config.sourceDir, config.destDir) : false;
1695
+
1696
+ if (exists && equal) {
1697
+ if (!hasState) {
1698
+ log(options, `[skip] Spec ${assetName} 已存在且一致,视为已启用: ${config.destDir}`);
1699
+ state.assets[assetName] = {
1700
+ destPath: config.destDir,
1701
+ backupPath: "",
1702
+ installedAt: nowISO(),
1703
+ mode: "kept",
1704
+ };
1705
+ }
1578
1706
  continue;
1579
1707
  }
1580
1708
 
1581
- const exists = fs.existsSync(config.destDir);
1582
1709
  let action = exists ? "backup" : "";
1583
1710
  if (exists && prompter) {
1584
1711
  action = await prompter.resolveConflict({
@@ -1586,6 +1713,8 @@ async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) {
1586
1713
  label: `Spec ${assetName}`,
1587
1714
  path: config.destDir,
1588
1715
  });
1716
+ } else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
1717
+ action = "backup";
1589
1718
  }
1590
1719
 
1591
1720
  if (action === "keep") {
@@ -1650,26 +1779,59 @@ function restoreSpecAsset(assetState, options, label) {
1650
1779
  }
1651
1780
 
1652
1781
  async function enableSpecTarget(targetName, state, timestamp, options, prompter) {
1653
- if (state.targets[targetName]) {
1654
- log(options, `[skip] Spec 目标已启用: ${targetName}`);
1655
- return;
1656
- }
1657
-
1658
1782
  const destinations = getGlobalDestinations(targetName);
1659
- const targetState = {
1783
+ const existingTargetState = state.targets[targetName];
1784
+ if (existingTargetState) {
1785
+ log(options, `[info] Spec 目标已启用,执行一致性修复: ${targetName}`);
1786
+ }
1787
+ const targetState = existingTargetState || {
1660
1788
  enabledAt: nowISO(),
1661
1789
  consumers: {},
1662
1790
  };
1663
1791
 
1664
1792
  for (const destination of destinations) {
1793
+ const existingConsumerState =
1794
+ targetState.consumers && targetState.consumers[destination.id] ? targetState.consumers[destination.id] : null;
1665
1795
  const consumerState = {
1666
1796
  skills: [],
1667
1797
  };
1798
+ const existingSkills = new Map();
1799
+ if (existingConsumerState && Array.isArray(existingConsumerState.skills)) {
1800
+ for (const skill of existingConsumerState.skills) {
1801
+ if (skill && typeof skill.name === "string") {
1802
+ existingSkills.set(skill.name, skill);
1803
+ }
1804
+ }
1805
+ }
1668
1806
 
1669
1807
  for (const skillName of SPEC_SKILL_NAMES) {
1670
1808
  const srcDir = path.join(BUNDLED_SPEC_DIR, "skills", skillName);
1671
1809
  const destDir = path.join(destination.skillsRoot, skillName);
1810
+ const existingSkillState = existingSkills.get(skillName);
1811
+ const mode = normalizeSpecAssetMode(existingSkillState);
1672
1812
  const exists = fs.existsSync(destDir);
1813
+ const equal = exists ? areDirectoriesEqual(srcDir, destDir) : false;
1814
+
1815
+ if (mode === "kept" && exists) {
1816
+ consumerState.skills.push({
1817
+ name: skillName,
1818
+ destPath: destDir,
1819
+ backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
1820
+ mode: "kept",
1821
+ });
1822
+ continue;
1823
+ }
1824
+
1825
+ if (exists && equal) {
1826
+ consumerState.skills.push({
1827
+ name: skillName,
1828
+ destPath: destDir,
1829
+ backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
1830
+ mode: existingSkillState && existingSkillState.mode ? existingSkillState.mode : "kept",
1831
+ });
1832
+ continue;
1833
+ }
1834
+
1673
1835
  let action = exists ? "backup" : "";
1674
1836
  if (exists && prompter) {
1675
1837
  action = await prompter.resolveConflict({
@@ -1677,6 +1839,8 @@ async function enableSpecTarget(targetName, state, timestamp, options, prompter)
1677
1839
  label: `Spec Skill ${destination.id}/${skillName}`,
1678
1840
  path: destDir,
1679
1841
  });
1842
+ } else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
1843
+ action = "backup";
1680
1844
  }
1681
1845
 
1682
1846
  if (action === "keep") {
@@ -1684,13 +1848,13 @@ async function enableSpecTarget(targetName, state, timestamp, options, prompter)
1684
1848
  consumerState.skills.push({
1685
1849
  name: skillName,
1686
1850
  destPath: destDir,
1687
- backupPath: "",
1851
+ backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
1688
1852
  mode: "kept",
1689
1853
  });
1690
1854
  continue;
1691
1855
  }
1692
1856
 
1693
- let backupPath = "";
1857
+ let backupPath = existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "";
1694
1858
  if (exists && action !== "remove") {
1695
1859
  backupPath = backupDirSnapshot(
1696
1860
  destDir,
@@ -1742,14 +1906,28 @@ function disableSpecTarget(targetName, state, options) {
1742
1906
  }
1743
1907
 
1744
1908
  function evaluateSpecState() {
1909
+ const statePath = getSpecStatePath();
1910
+ const hasStateFile = fs.existsSync(statePath);
1745
1911
  const { state } = readSpecState();
1746
1912
  const targetNames = Object.keys(state.targets || {});
1747
1913
  if (targetNames.length === 0) {
1914
+ const specHome = getSpecHomeDir();
1915
+ const orphanIssues = collectSpecOrphanIssues(specHome, hasStateFile);
1916
+ if (orphanIssues.length > 0) {
1917
+ return {
1918
+ state: "broken",
1919
+ targets: [],
1920
+ assets: state.assets || {},
1921
+ specHome,
1922
+ issues: orphanIssues,
1923
+ };
1924
+ }
1748
1925
  return {
1749
1926
  state: "missing",
1750
1927
  targets: [],
1751
1928
  assets: state.assets || {},
1752
- specHome: getSpecHomeDir(),
1929
+ specHome,
1930
+ issues: [],
1753
1931
  };
1754
1932
  }
1755
1933
 
@@ -1765,10 +1943,23 @@ function evaluateSpecState() {
1765
1943
  }
1766
1944
  }
1767
1945
 
1768
- for (const assetName of ["templates", "references"]) {
1946
+ const specHome = getSpecHomeDir();
1947
+ const assetRequirements = {
1948
+ templates: { dir: path.join(specHome, "templates"), files: SPEC_TEMPLATE_REQUIRED_FILES },
1949
+ references: { dir: path.join(specHome, "references"), files: SPEC_REFERENCE_REQUIRED_FILES },
1950
+ profiles: { dir: path.join(specHome, "profiles"), files: SPEC_PROFILE_REQUIRED_FILES },
1951
+ };
1952
+
1953
+ for (const assetName of ["templates", "references", "profiles"]) {
1769
1954
  const asset = state.assets[assetName];
1770
1955
  if (!asset || !asset.destPath || !fs.existsSync(asset.destPath)) {
1771
1956
  issues.push(`Missing spec asset: ${assetName}`);
1957
+ continue;
1958
+ }
1959
+ const requirement = assetRequirements[assetName];
1960
+ const missing = listMissingFiles(requirement.dir, requirement.files);
1961
+ for (const rel of missing) {
1962
+ issues.push(`Missing spec asset file: ${assetName}/${rel}`);
1772
1963
  }
1773
1964
  }
1774
1965
 
@@ -1777,7 +1968,7 @@ function evaluateSpecState() {
1777
1968
  targets: targetNames,
1778
1969
  issues,
1779
1970
  assets: state.assets || {},
1780
- specHome: getSpecHomeDir(),
1971
+ specHome,
1781
1972
  };
1782
1973
  }
1783
1974
 
@@ -1808,6 +1999,294 @@ function commandSpecStatus(options) {
1808
1999
  setQuietStatusExitCode(summary.state);
1809
2000
  }
1810
2001
 
2002
+ function stripUtf8Bom(text) {
2003
+ if (!text) return "";
2004
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
2005
+ }
2006
+
2007
+ function parseCsvLine(line) {
2008
+ const cells = [];
2009
+ let current = "";
2010
+ let inQuotes = false;
2011
+
2012
+ for (let i = 0; i < line.length; i++) {
2013
+ const ch = line[i];
2014
+ if (ch === "\"") {
2015
+ if (inQuotes && line[i + 1] === "\"") {
2016
+ current += "\"";
2017
+ i += 1;
2018
+ continue;
2019
+ }
2020
+ inQuotes = !inQuotes;
2021
+ continue;
2022
+ }
2023
+ if (ch === "," && !inQuotes) {
2024
+ cells.push(current);
2025
+ current = "";
2026
+ continue;
2027
+ }
2028
+ current += ch;
2029
+ }
2030
+ cells.push(current);
2031
+ return cells;
2032
+ }
2033
+
2034
+ function analyzeIssuesCsv(issuesPath) {
2035
+ if (!fs.existsSync(issuesPath)) {
2036
+ return { status: "missing", issues: ["Missing issues.csv"], stats: { total: 0, inProgress: 0 } };
2037
+ }
2038
+
2039
+ const raw = stripUtf8Bom(fs.readFileSync(issuesPath, "utf8"));
2040
+ const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
2041
+ if (lines.length === 0) {
2042
+ return { status: "broken", issues: ["issues.csv is empty"], stats: { total: 0, inProgress: 0 } };
2043
+ }
2044
+
2045
+ const header = parseCsvLine(lines[0]).map((cell) => cell.trim());
2046
+ const statusIndex = header.findIndex((cell) => cell === "状态" || cell.includes("(状态)") || /状态/.test(cell));
2047
+ if (statusIndex < 0) {
2048
+ return { status: "broken", issues: ["issues.csv header missing 状态 column"], stats: { total: Math.max(0, lines.length - 1), inProgress: 0 } };
2049
+ }
2050
+
2051
+ const allowedStates = new Set(["未开始", "进行中", "已完成"]);
2052
+ let total = 0;
2053
+ let inProgress = 0;
2054
+ const issues = [];
2055
+
2056
+ for (let i = 1; i < lines.length; i++) {
2057
+ const row = parseCsvLine(lines[i]);
2058
+ const isEmpty = row.every((cell) => String(cell || "").trim() === "");
2059
+ if (isEmpty) {
2060
+ continue;
2061
+ }
2062
+ total += 1;
2063
+ const state = String(row[statusIndex] || "").trim();
2064
+ if (!allowedStates.has(state)) {
2065
+ issues.push(`Invalid 状态 at row ${i + 1}: ${state || "(empty)"}`);
2066
+ continue;
2067
+ }
2068
+ if (state === "进行中") {
2069
+ inProgress += 1;
2070
+ }
2071
+ }
2072
+
2073
+ if (inProgress > 1) {
2074
+ issues.push(`Multiple tasks in 进行中: ${inProgress}`);
2075
+ }
2076
+
2077
+ return {
2078
+ status: issues.length > 0 ? "broken" : "ok",
2079
+ issues,
2080
+ stats: { total, inProgress },
2081
+ };
2082
+ }
2083
+
2084
+ function checkSpecProjectIntegrity(workspaceRoot) {
2085
+ const issues = [];
2086
+ const issuesPath = path.join(workspaceRoot, "issues.csv");
2087
+ const issuesResult = analyzeIssuesCsv(issuesPath);
2088
+
2089
+ const specDir = path.join(workspaceRoot, ".ling", "spec");
2090
+ const templatesDir = path.join(specDir, "templates");
2091
+ const referencesDir = path.join(specDir, "references");
2092
+ const profilesDir = path.join(specDir, "profiles");
2093
+
2094
+ const hasSpecDir = fs.existsSync(specDir);
2095
+ if (!hasSpecDir) {
2096
+ if (issuesResult.status !== "missing") {
2097
+ issues.push("Missing .ling/spec directory (run: ling spec init)");
2098
+ }
2099
+ } else {
2100
+ if (issuesResult.status === "missing") {
2101
+ issues.push("Missing issues.csv (run: ling spec init)");
2102
+ }
2103
+ for (const rel of SPEC_TEMPLATE_REQUIRED_FILES) {
2104
+ const filePath = path.join(templatesDir, rel);
2105
+ if (!fs.existsSync(filePath)) {
2106
+ issues.push(`Missing spec template: .ling/spec/templates/${rel}`);
2107
+ }
2108
+ }
2109
+ for (const rel of SPEC_REFERENCE_REQUIRED_FILES) {
2110
+ const filePath = path.join(referencesDir, rel);
2111
+ if (!fs.existsSync(filePath)) {
2112
+ issues.push(`Missing spec reference: .ling/spec/references/${rel}`);
2113
+ }
2114
+ }
2115
+ for (const rel of SPEC_PROFILE_REQUIRED_FILES) {
2116
+ const filePath = path.join(profilesDir, ...rel.split("/"));
2117
+ if (!fs.existsSync(filePath)) {
2118
+ issues.push(`Missing spec profile: .ling/spec/profiles/${rel}`);
2119
+ }
2120
+ }
2121
+ }
2122
+
2123
+ if (issuesResult.status === "broken") {
2124
+ issues.push(...issuesResult.issues);
2125
+ }
2126
+
2127
+ const hasAnySpecSignal = hasSpecDir || issuesResult.status !== "missing";
2128
+ if (!hasAnySpecSignal) {
2129
+ return { status: "missing", issues: [], stats: { total: 0, inProgress: 0 } };
2130
+ }
2131
+
2132
+ return {
2133
+ status: issues.length > 0 ? "broken" : "ok",
2134
+ issues,
2135
+ stats: issuesResult.stats,
2136
+ };
2137
+ }
2138
+
2139
+ function resolveWorkspaceSpecBackupRoot(workspaceRoot, timestamp) {
2140
+ return path.join(workspaceRoot, ".ling", "backups", "spec", timestamp, "before");
2141
+ }
2142
+
2143
+ async function commandSpecInit(options) {
2144
+ const workspaceRoot = options.path ? resolveWorkspaceRoot(options.path) : getSpecWorkspaceDir();
2145
+ const prompter = createConflictPrompter(options);
2146
+ const timestamp = nowISO().replace(/[:.]/g, "-");
2147
+ const backupRoot = resolveWorkspaceSpecBackupRoot(workspaceRoot, timestamp);
2148
+
2149
+ const specDir = path.join(workspaceRoot, ".ling", "spec");
2150
+ let specSourceDir = BUNDLED_SPEC_DIR;
2151
+ let cleanupSpec = null;
2152
+ if (options.branch) {
2153
+ const remote = cloneBranchSpecDir(options.branch, {
2154
+ quiet: options.quiet,
2155
+ logger: log.bind(null, options),
2156
+ });
2157
+ specSourceDir = remote.specDir;
2158
+ cleanupSpec = remote.cleanup;
2159
+ }
2160
+ const assets = {
2161
+ templates: {
2162
+ sourceDir: path.join(specSourceDir, "templates"),
2163
+ destDir: path.join(specDir, "templates"),
2164
+ },
2165
+ references: {
2166
+ sourceDir: path.join(specSourceDir, "references"),
2167
+ destDir: path.join(specDir, "references"),
2168
+ },
2169
+ profiles: {
2170
+ sourceDir: path.join(specSourceDir, "profiles"),
2171
+ destDir: path.join(specDir, "profiles"),
2172
+ },
2173
+ };
2174
+
2175
+ try {
2176
+ fs.mkdirSync(workspaceRoot, { recursive: true });
2177
+
2178
+ for (const [assetName, config] of Object.entries(assets)) {
2179
+ const exists = fs.existsSync(config.destDir);
2180
+ if (exists && areDirectoriesEqual(config.sourceDir, config.destDir)) {
2181
+ log(options, `[skip] Spec ${assetName} 已最新,无需覆盖: ${config.destDir}`);
2182
+ continue;
2183
+ }
2184
+
2185
+ let action = exists ? "backup" : "";
2186
+ if (exists && prompter) {
2187
+ action = await prompter.resolveConflict({
2188
+ category: "spec:project:assets",
2189
+ label: `Spec ${assetName}`,
2190
+ path: config.destDir,
2191
+ });
2192
+ } else if (exists && !prompter && (options.force || options.nonInteractive || !process.stdin.isTTY)) {
2193
+ action = "backup";
2194
+ }
2195
+
2196
+ if (action === "keep") {
2197
+ log(options, `[skip] 已保留 Spec ${assetName} 资产,不覆盖: ${config.destDir}`);
2198
+ continue;
2199
+ }
2200
+
2201
+ if (exists && action !== "remove") {
2202
+ backupDirSnapshot(config.destDir, path.join(backupRoot, "assets", assetName), options, `Spec ${assetName}`);
2203
+ } else if (exists && action === "remove") {
2204
+ removeDirIfExists(config.destDir, options, `Spec ${assetName}`);
2205
+ }
2206
+ applyDirSnapshot(config.sourceDir, config.destDir, options, `Spec ${assetName}`);
2207
+ }
2208
+
2209
+ const issuesPath = path.join(workspaceRoot, "issues.csv");
2210
+ const issuesTemplatePath = path.join(specSourceDir, "templates", "issues.template.csv");
2211
+ const issuesTemplate = stripUtf8Bom(fs.readFileSync(issuesTemplatePath, "utf8"));
2212
+ const hasIssues = fs.existsSync(issuesPath);
2213
+ if (hasIssues) {
2214
+ let action = "backup";
2215
+ if (prompter) {
2216
+ action = await prompter.resolveConflict({
2217
+ category: "spec:project:file",
2218
+ label: "issues.csv",
2219
+ path: issuesPath,
2220
+ });
2221
+ }
2222
+ if (action === "keep") {
2223
+ log(options, "[skip] 已保留现有 issues.csv,不覆盖");
2224
+ } else {
2225
+ if (action !== "remove") {
2226
+ backupFileSnapshot(issuesPath, path.join(backupRoot, "issues.csv"), options, "issues.csv");
2227
+ } else if (!options.dryRun) {
2228
+ fs.rmSync(issuesPath, { force: true });
2229
+ }
2230
+ atomicWriteFile(issuesPath, `${issuesTemplate.trimEnd()}\n`, options, "issues.csv");
2231
+ log(options, options.dryRun ? `[dry-run] 将写入任务跟踪文件: ${issuesPath}` : `[ok] 已写入任务跟踪文件: ${issuesPath}`);
2232
+ }
2233
+ } else {
2234
+ atomicWriteFile(issuesPath, `${issuesTemplate.trimEnd()}\n`, options, "issues.csv");
2235
+ log(options, options.dryRun ? `[dry-run] 将创建任务跟踪文件: ${issuesPath}` : `[ok] 已创建任务跟踪文件: ${issuesPath}`);
2236
+ }
2237
+
2238
+ const docsReviewsDir = path.join(workspaceRoot, "docs", "reviews");
2239
+ const docsHandoffDir = path.join(workspaceRoot, "docs", "handoff");
2240
+ if (options.dryRun) {
2241
+ log(options, `[dry-run] 将确保目录存在: ${docsReviewsDir}`);
2242
+ log(options, `[dry-run] 将确保目录存在: ${docsHandoffDir}`);
2243
+ } else {
2244
+ fs.mkdirSync(docsReviewsDir, { recursive: true });
2245
+ fs.mkdirSync(docsHandoffDir, { recursive: true });
2246
+ }
2247
+
2248
+ const requestedTargets = normalizeTargets(options.targets);
2249
+ const shouldInitTargets = options.path ? requestedTargets.length > 0 : true;
2250
+ const targets = shouldInitTargets ? (requestedTargets.length > 0 ? requestedTargets : [...SUPPORTED_TARGETS]) : [];
2251
+ if (targets.length > 0) {
2252
+ await initTargets(workspaceRoot, targets, options, prompter);
2253
+ }
2254
+
2255
+ log(options, `[ok] Spec 初始化完成: ${workspaceRoot}`);
2256
+ } finally {
2257
+ if (cleanupSpec) cleanupSpec();
2258
+ if (prompter) prompter.close();
2259
+ }
2260
+ }
2261
+
2262
+ function commandSpecDoctor(options) {
2263
+ const workspaceRoot = options.path ? resolveWorkspaceRoot(options.path) : getSpecWorkspaceDir();
2264
+ const result = checkSpecProjectIntegrity(workspaceRoot);
2265
+ const state = result.status === "ok" ? "installed" : result.status;
2266
+
2267
+ if (options.quiet) {
2268
+ console.log(state);
2269
+ setQuietStatusExitCode(state);
2270
+ return;
2271
+ }
2272
+
2273
+ if (state === "missing") {
2274
+ console.log("[warn] 未检测到 Spec 项目资产");
2275
+ console.log(` 工作区: ${workspaceRoot}`);
2276
+ setQuietStatusExitCode("missing");
2277
+ return;
2278
+ }
2279
+
2280
+ console.log(state === "installed" ? "[ok] Spec 项目资产状态正常" : "[warn] Spec 项目资产存在问题");
2281
+ console.log(` 工作区: ${workspaceRoot}`);
2282
+ console.log(` 任务数: ${result.stats.total}`);
2283
+ console.log(` 进行中: ${result.stats.inProgress}`);
2284
+ for (const issue of result.issues || []) {
2285
+ console.log(` Issue: ${issue}`);
2286
+ }
2287
+ setQuietStatusExitCode(state);
2288
+ }
2289
+
1811
2290
  async function commandSpecEnable(options) {
1812
2291
  ensureBundledSpecResources();
1813
2292
  const targets = resolveTargetsForSpec(options);
@@ -1844,6 +2323,7 @@ function commandSpecDisable(options) {
1844
2323
  if (remainingTargets.length === 0) {
1845
2324
  restoreSpecAsset(state.assets.templates, options, "Spec templates");
1846
2325
  restoreSpecAsset(state.assets.references, options, "Spec references");
2326
+ restoreSpecAsset(state.assets.profiles, options, "Spec profiles");
1847
2327
  state.assets = {};
1848
2328
  }
1849
2329
 
@@ -1851,6 +2331,14 @@ function commandSpecDisable(options) {
1851
2331
  if (!options.dryRun) {
1852
2332
  if (remainingTargets.length === 0) {
1853
2333
  removeSpecStateFile();
2334
+ const specHome = getSpecHomeDir();
2335
+ try {
2336
+ if (fs.existsSync(specHome) && fs.readdirSync(specHome).length === 0) {
2337
+ fs.rmdirSync(specHome);
2338
+ }
2339
+ } catch (err) {
2340
+ log(options, `[warn] 无法清理 Spec 根目录: ${err.message}`);
2341
+ }
1854
2342
  } else {
1855
2343
  writeSpecState(statePath, state);
1856
2344
  }
@@ -1870,87 +2358,97 @@ async function commandSpec(options) {
1870
2358
  if (subcommand === "disable") {
1871
2359
  return commandSpecDisable(options);
1872
2360
  }
2361
+ if (subcommand === "init") {
2362
+ return await commandSpecInit(options);
2363
+ }
2364
+ if (subcommand === "doctor") {
2365
+ return commandSpecDoctor(options);
2366
+ }
1873
2367
  throw new Error(`未知 spec 子命令: ${subcommand}`);
1874
2368
  }
1875
2369
 
1876
- async function commandInit(options) {
1877
- const workspaceRoot = resolveWorkspaceRoot(options.path);
1878
- const targets = await resolveTargetsForInit(options);
1879
- const prompter = createConflictPrompter(options);
2370
+ async function initTargets(workspaceRoot, targets, options, prompter) {
2371
+ for (const target of targets) {
2372
+ const runOptions = { ...options };
2373
+ const conflicts = [];
1880
2374
 
1881
- try {
1882
- for (const target of targets) {
1883
- const runOptions = { ...options };
1884
- const conflicts = [];
2375
+ if (target === "gemini") {
2376
+ const agentDir = path.join(workspaceRoot, ".agent");
2377
+ if (fs.existsSync(agentDir)) {
2378
+ conflicts.push({
2379
+ category: "project:gemini",
2380
+ label: ".agent",
2381
+ path: agentDir,
2382
+ target,
2383
+ });
2384
+ }
2385
+ }
1885
2386
 
1886
- if (target === "gemini") {
1887
- const agentDir = path.join(workspaceRoot, ".agent");
1888
- if (fs.existsSync(agentDir)) {
2387
+ if (target === "codex") {
2388
+ const managedDir = path.join(workspaceRoot, ".agents");
2389
+ const legacyDir = path.join(workspaceRoot, ".codex");
2390
+ if (fs.existsSync(managedDir) || fs.existsSync(legacyDir)) {
2391
+ if (fs.existsSync(managedDir)) {
1889
2392
  conflicts.push({
1890
- category: "project:gemini",
1891
- label: ".agent",
1892
- path: agentDir,
2393
+ category: "project:codex",
2394
+ label: ".agents",
2395
+ path: managedDir,
1893
2396
  target,
1894
2397
  });
1895
2398
  }
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
- }
2399
+ if (fs.existsSync(legacyDir)) {
2400
+ conflicts.push({
2401
+ category: "project:codex",
2402
+ label: ".codex",
2403
+ path: legacyDir,
2404
+ target,
2405
+ });
1918
2406
  }
1919
2407
  }
2408
+ }
1920
2409
 
1921
- if (conflicts.length > 0) {
1922
- if (!prompter && !runOptions.force) {
1923
- throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。");
1924
- }
2410
+ if (conflicts.length > 0) {
2411
+ if (!prompter && !runOptions.force) {
2412
+ throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。");
2413
+ }
1925
2414
 
1926
- const timestamp = nowISO().replace(/[:.]/g, "-");
1927
- let shouldSkip = false;
2415
+ const timestamp = nowISO().replace(/[:.]/g, "-");
2416
+ let shouldSkip = false;
1928
2417
 
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;
2418
+ for (const conflict of conflicts) {
2419
+ const action = prompter ? await prompter.resolveConflict(conflict) : "backup";
2420
+ if (action === "keep") {
2421
+ log(options, `[skip] 已保留现有资产,跳过初始化: ${conflict.label}`);
2422
+ shouldSkip = true;
2423
+ break;
1942
2424
  }
1943
-
1944
- if (shouldSkip) {
1945
- continue;
2425
+ if (action === "backup") {
2426
+ const backupRootName = conflict.label === ".agent" ? ".agent-backup" : ".agents-backup";
2427
+ backupWorkspaceDir(workspaceRoot, conflict.path, backupRootName, timestamp, options, `工作区资产 ${conflict.label}`);
1946
2428
  }
2429
+ // remove/backup 都需要强制覆盖才能继续
2430
+ runOptions.force = true;
1947
2431
  }
1948
2432
 
1949
- const adapter = createAdapter(target, workspaceRoot, runOptions);
1950
- log(options, `[sync] 正在初始化目标 [${target}] ...`);
1951
- adapter.install(BUNDLED_AGENT_DIR);
1952
- registerWorkspaceTarget(workspaceRoot, target, runOptions);
2433
+ if (shouldSkip) {
2434
+ continue;
2435
+ }
1953
2436
  }
2437
+
2438
+ const adapter = createAdapter(target, workspaceRoot, runOptions);
2439
+ log(options, `[sync] 正在初始化目标 [${target}] ...`);
2440
+ adapter.install(BUNDLED_AGENT_DIR);
2441
+ registerWorkspaceTarget(workspaceRoot, target, runOptions);
2442
+ }
2443
+ }
2444
+
2445
+ async function commandInit(options) {
2446
+ const workspaceRoot = resolveWorkspaceRoot(options.path);
2447
+ const targets = await resolveTargetsForInit(options);
2448
+ const prompter = createConflictPrompter(options);
2449
+
2450
+ try {
2451
+ await initTargets(workspaceRoot, targets, options, prompter);
1954
2452
  } finally {
1955
2453
  if (prompter) prompter.close();
1956
2454
  }
@@ -2385,6 +2883,22 @@ async function commandDoctor(options) {
2385
2883
  }
2386
2884
  }
2387
2885
 
2886
+ const specResult = checkSpecProjectIntegrity(workspaceRoot);
2887
+ if (specResult.status !== "missing") {
2888
+ out(`\n[SPEC] 检查 Spec 项目资产...`);
2889
+ if (specResult.status === "ok") {
2890
+ out(" [ok] 状态正常");
2891
+ out(` - 任务数: ${specResult.stats.total}, 进行中: ${specResult.stats.inProgress}`);
2892
+ } else {
2893
+ out(` [error] 状态: ${specResult.status}`);
2894
+ out(` - 任务数: ${specResult.stats.total}, 进行中: ${specResult.stats.inProgress}`);
2895
+ for (const issue of specResult.issues || []) {
2896
+ out(` - ${issue}`);
2897
+ }
2898
+ hasIssue = true;
2899
+ }
2900
+ }
2901
+
2388
2902
  if (hasIssue) {
2389
2903
  process.exitCode = 1;
2390
2904
  }