@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/.agents/rules/GEMINI.md +17 -0
- package/.agents/skills/clean-code/SKILL.md +24 -14
- package/.agents/skills/doc.md +9 -5
- package/CHANGELOG.md +15 -1
- package/bin/core/generator.js +1 -0
- package/bin/ling-cli.js +590 -76
- package/bin/utils.js +52 -9
- package/docs/TECH.md +21 -6
- package/package.json +1 -1
- package/tests/spec-init-doctor.test.js +175 -0
- package/tests/spec-profile.test.js +68 -0
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
|
-
|
|
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
|
|
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
|
|
1929
|
+
specHome,
|
|
1930
|
+
issues: [],
|
|
1753
1931
|
};
|
|
1754
1932
|
}
|
|
1755
1933
|
|
|
@@ -1765,10 +1943,23 @@ function evaluateSpecState() {
|
|
|
1765
1943
|
}
|
|
1766
1944
|
}
|
|
1767
1945
|
|
|
1768
|
-
|
|
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
|
|
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
|
|
1877
|
-
const
|
|
1878
|
-
|
|
1879
|
-
|
|
2370
|
+
async function initTargets(workspaceRoot, targets, options, prompter) {
|
|
2371
|
+
for (const target of targets) {
|
|
2372
|
+
const runOptions = { ...options };
|
|
2373
|
+
const conflicts = [];
|
|
1880
2374
|
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
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:
|
|
1891
|
-
label: ".
|
|
1892
|
-
path:
|
|
2393
|
+
category: "project:codex",
|
|
2394
|
+
label: ".agents",
|
|
2395
|
+
path: managedDir,
|
|
1893
2396
|
target,
|
|
1894
2397
|
});
|
|
1895
2398
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2410
|
+
if (conflicts.length > 0) {
|
|
2411
|
+
if (!prompter && !runOptions.force) {
|
|
2412
|
+
throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。");
|
|
2413
|
+
}
|
|
1925
2414
|
|
|
1926
|
-
|
|
1927
|
-
|
|
2415
|
+
const timestamp = nowISO().replace(/[:.]/g, "-");
|
|
2416
|
+
let shouldSkip = false;
|
|
1928
2417
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
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
|
-
|
|
1945
|
-
|
|
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
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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
|
}
|