@mison/ling 1.0.2 → 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.
@@ -5,19 +5,17 @@ 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");
12
12
  const GeminiAdapter = require("./adapters/gemini");
13
13
  const CodexAdapter = require("./adapters/codex");
14
- const { selectTargets } = require("./interactive");
14
+ const { selectTargets, createConflictPrompter } = require("./interactive");
15
15
 
16
16
  const BUNDLED_AGENT_DIR = path.resolve(__dirname, "../.agents");
17
17
  const BUNDLED_SPEC_DIR = path.resolve(__dirname, "../.spec");
18
18
  const PRIMARY_CLI_NAME = "ling";
19
- const LEGACY_CLI_NAME = "ag-kit";
20
- const CURRENT_CLI_BASENAME = path.basename(process.argv[1] || "", path.extname(process.argv[1] || "")) || PRIMARY_CLI_NAME;
21
19
  const WORKSPACE_INDEX_VERSION = 2;
22
20
  const UPSTREAM_GLOBAL_PACKAGE = "@vudovn/ag-kit";
23
21
  const TOOLKIT_PACKAGE_NAMES = new Set(["@mison/ling", "@mison/ag-kit-cn", "antigravity-kit-cn", "antigravity-kit"]);
@@ -57,6 +55,9 @@ const QUIET_STATUS_EXIT_CODES = {
57
55
  const SPEC_STATE_VERSION = 1;
58
56
  const SPEC_SKILL_NAMES = ["harness-engineering", "cybernetic-systems-engineering"];
59
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"];
60
61
 
61
62
  function nowISO() {
62
63
  return new Date().toISOString();
@@ -67,36 +68,11 @@ function getVersionTag() {
67
68
  }
68
69
 
69
70
  function getControlHomeDir() {
70
- const preferred = path.join(os.homedir(), ".ling");
71
- const legacy = path.join(os.homedir(), ".ag-kit");
72
-
73
- if (fs.existsSync(preferred)) {
74
- return preferred;
75
- }
76
- if (fs.existsSync(legacy)) {
77
- return legacy;
78
- }
79
- return preferred;
80
- }
81
-
82
- function migrateLegacyControlHomeDir() {
83
- if (process.env.LING_INDEX_PATH || process.env.AG_KIT_INDEX_PATH) {
84
- return null;
85
- }
86
-
87
- const preferred = path.join(os.homedir(), ".ling");
88
- const legacy = path.join(os.homedir(), ".ag-kit");
89
-
90
- if (fs.existsSync(preferred) || !fs.existsSync(legacy)) {
91
- return null;
92
- }
93
-
94
- copyDirRecursive(legacy, preferred);
95
- return { from: legacy, to: preferred };
71
+ return path.join(os.homedir(), ".ling");
96
72
  }
97
73
 
98
74
  function getWorkspaceIndexPath() {
99
- const customPath = process.env.LING_INDEX_PATH || process.env.AG_KIT_INDEX_PATH;
75
+ const customPath = process.env.LING_INDEX_PATH;
100
76
  if (customPath) {
101
77
  return path.resolve(process.cwd(), customPath);
102
78
  }
@@ -113,7 +89,7 @@ function createEmptyWorkspaceIndex() {
113
89
  }
114
90
 
115
91
  function resolveGlobalRootDir() {
116
- const customRoot = process.env.LING_GLOBAL_ROOT || process.env.AG_KIT_GLOBAL_ROOT;
92
+ const customRoot = process.env.LING_GLOBAL_ROOT;
117
93
  if (typeof customRoot === "string" && customRoot.trim()) {
118
94
  return path.resolve(process.cwd(), customRoot);
119
95
  }
@@ -156,6 +132,22 @@ function copyDirRecursive(src, dest) {
156
132
  }
157
133
  }
158
134
 
135
+ function backupWorkspaceDir(workspaceRoot, sourceDir, backupRootName, timestamp, options, label) {
136
+ if (!fs.existsSync(sourceDir)) {
137
+ return "";
138
+ }
139
+ const assetName = path.basename(sourceDir) || "asset";
140
+ const backupDir = path.join(workspaceRoot, backupRootName, timestamp, "preflight", assetName);
141
+ if (options.dryRun) {
142
+ log(options, `[dry-run] 将备份 ${label}: ${sourceDir} -> ${backupDir}`);
143
+ return backupDir;
144
+ }
145
+ fs.mkdirSync(path.dirname(backupDir), { recursive: true });
146
+ copyDirRecursive(sourceDir, backupDir);
147
+ log(options, `[backup] 已备份 ${label}: ${sourceDir} -> ${backupDir}`);
148
+ return backupDir;
149
+ }
150
+
159
151
  function areDirectoriesEqual(leftDir, rightDir) {
160
152
  const left = ManifestManager.generateFromDir(leftDir);
161
153
  const right = ManifestManager.generateFromDir(rightDir);
@@ -183,6 +175,8 @@ function printUsage() {
183
175
  console.log(` ${PRIMARY_CLI_NAME} spec enable [--target <name>|--targets <a,b>] [--quiet] [--dry-run]`);
184
176
  console.log(` ${PRIMARY_CLI_NAME} spec disable [--target <name>|--targets <a,b>] [--quiet] [--dry-run]`);
185
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]`);
186
180
  console.log(` ${PRIMARY_CLI_NAME} exclude list [--quiet]`);
187
181
  console.log(` ${PRIMARY_CLI_NAME} exclude add --path <dir> [--dry-run] [--quiet]`);
188
182
  console.log(` ${PRIMARY_CLI_NAME} exclude remove --path <dir> [--dry-run] [--quiet]`);
@@ -301,6 +295,8 @@ const COMMAND_ALLOWED_FLAGS = {
301
295
  "spec:enable": ["--target", "--targets", "--quiet", "--dry-run"],
302
296
  "spec:disable": ["--target", "--targets", "--quiet", "--dry-run"],
303
297
  "spec:status": ["--quiet"],
298
+ "spec:init": ["--force", "--path", "--branch", "--target", "--targets", "--non-interactive", "--no-index", "--quiet", "--dry-run"],
299
+ "spec:doctor": ["--path", "--quiet"],
304
300
  "exclude:list": ["--quiet"],
305
301
  "exclude:add": ["--path", "--dry-run", "--quiet"],
306
302
  "exclude:remove": ["--path", "--dry-run", "--quiet"],
@@ -428,10 +424,10 @@ function isPathExcludedByList(excludedPaths, workspacePath) {
428
424
 
429
425
  function isToolkitSourceDirectory(workspacePath) {
430
426
  const packageJsonPath = path.join(workspacePath, "package.json");
431
- const legacyCliPath = path.join(workspacePath, "bin", "ag-kit.js");
427
+ const cliPath = path.join(workspacePath, "bin", "ling-cli.js");
432
428
  const primaryCliPath = path.join(workspacePath, "bin", "ling.js");
433
429
 
434
- if (!fs.existsSync(packageJsonPath) || (!fs.existsSync(legacyCliPath) && !fs.existsSync(primaryCliPath))) {
430
+ if (!fs.existsSync(packageJsonPath) || (!fs.existsSync(cliPath) && !fs.existsSync(primaryCliPath))) {
435
431
  return false;
436
432
  }
437
433
 
@@ -856,7 +852,7 @@ function maybeWarnUpstreamGlobalConflict(command, options) {
856
852
  if (options.quiet) {
857
853
  return;
858
854
  }
859
- if (process.env.LING_SKIP_UPSTREAM_CHECK === "1" || process.env.AG_KIT_SKIP_UPSTREAM_CHECK === "1") {
855
+ if (process.env.LING_SKIP_UPSTREAM_CHECK === "1") {
860
856
  return;
861
857
  }
862
858
  const shouldWarn =
@@ -879,20 +875,8 @@ function maybeWarnUpstreamGlobalConflict(command, options) {
879
875
  }
880
876
 
881
877
  log(options, `[warn] 检测到全局已安装上游英文版 ${UPSTREAM_GLOBAL_PACKAGE}。`);
882
- log(options, `[warn] 上游英文版与当前版本共用 \`${LEGACY_CLI_NAME}\` 兼容命令名,后安装者会覆盖该入口。`);
883
- log(options, `[hint] 建议执行: npm uninstall -g ${UPSTREAM_GLOBAL_PACKAGE}`);
884
- log(options, `[info] 正式命令已切换为 \`${PRIMARY_CLI_NAME}\`。若你通过 bun install -g 安装,Bun 默认会阻止本包 postinstall;因此这里会在首次执行 CLI 时再次提醒。`);
885
- }
886
-
887
- function maybeWarnLegacyCliAlias(options) {
888
- if (options.quiet) {
889
- return;
890
- }
891
- if (CURRENT_CLI_BASENAME !== LEGACY_CLI_NAME) {
892
- return;
893
- }
894
-
895
- console.log(`[warn] \`${LEGACY_CLI_NAME}\` 已进入兼容模式,请改用 \`${PRIMARY_CLI_NAME}\`.`);
878
+ log(options, `[hint] 如需仅保留一个来源,请执行: npm uninstall -g ${UPSTREAM_GLOBAL_PACKAGE}`);
879
+ log(options, `[info] 当前项目的正式命令为 \`${PRIMARY_CLI_NAME}\`。`);
896
880
  }
897
881
 
898
882
  function normalizeTargets(rawTargets) {
@@ -1219,9 +1203,106 @@ function syncGlobalSkillsFromRoot(targetName, skillsRoot, timestamp, options) {
1219
1203
  };
1220
1204
  }
1221
1205
 
1206
+ function planGlobalSyncTasks(targetName, agentDir) {
1207
+ const destinations = getGlobalDestinations(targetName);
1208
+
1209
+ if (targetName === "codex") {
1210
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ling-global-codex-"));
1211
+ const mockRoot = path.join(tempRoot, "source");
1212
+ const mockAgent = path.join(mockRoot, ".agents");
1213
+ const outputDir = path.join(tempRoot, "out");
1214
+
1215
+ copyDirRecursive(agentDir, mockAgent);
1216
+ CodexBuilder.build(mockRoot, outputDir);
1217
+ const skillsRoot = path.join(outputDir, "skills");
1218
+ const skillNames = listSkillDirectories(skillsRoot);
1219
+
1220
+ const tasks = [];
1221
+ for (const destination of destinations) {
1222
+ for (const skillName of skillNames) {
1223
+ tasks.push({
1224
+ destination,
1225
+ skillName,
1226
+ srcDir: path.join(skillsRoot, skillName),
1227
+ destDir: path.join(destination.skillsRoot, skillName),
1228
+ });
1229
+ }
1230
+ }
1231
+
1232
+ return {
1233
+ tasks,
1234
+ cleanup: () => fs.rmSync(tempRoot, { recursive: true, force: true }),
1235
+ };
1236
+ }
1237
+
1238
+ if (targetName === "gemini") {
1239
+ const skillsRoot = path.join(agentDir, "skills");
1240
+ const skillNames = listSkillDirectories(skillsRoot);
1241
+ const tasks = [];
1242
+ for (const destination of destinations) {
1243
+ for (const skillName of skillNames) {
1244
+ tasks.push({
1245
+ destination,
1246
+ skillName,
1247
+ srcDir: path.join(skillsRoot, skillName),
1248
+ destDir: path.join(destination.skillsRoot, skillName),
1249
+ });
1250
+ }
1251
+ }
1252
+ return { tasks, cleanup: null };
1253
+ }
1254
+
1255
+ throw new Error(`未知目标: ${targetName}`);
1256
+ }
1257
+
1258
+ function summarizeGlobalSync(tasks) {
1259
+ const perDestination = new Map();
1260
+ let synced = 0;
1261
+ let skipped = 0;
1262
+ let backedUp = 0;
1263
+
1264
+ for (const task of tasks) {
1265
+ const id = task.destination.id;
1266
+ if (!perDestination.has(id)) {
1267
+ perDestination.set(id, {
1268
+ targetName: id,
1269
+ family: task.destination.targetName,
1270
+ destRoot: task.destination.skillsRoot,
1271
+ total: 0,
1272
+ synced: 0,
1273
+ skipped: 0,
1274
+ backedUp: 0,
1275
+ });
1276
+ }
1277
+ const entry = perDestination.get(id);
1278
+ entry.total += 1;
1279
+ if (task.result === "skipped") {
1280
+ skipped += 1;
1281
+ entry.skipped += 1;
1282
+ continue;
1283
+ }
1284
+ if (task.result === "synced") {
1285
+ synced += 1;
1286
+ entry.synced += 1;
1287
+ }
1288
+ if (task.backedUp) {
1289
+ backedUp += 1;
1290
+ entry.backedUp += 1;
1291
+ }
1292
+ }
1293
+
1294
+ return {
1295
+ total: tasks.length,
1296
+ synced,
1297
+ skipped,
1298
+ backedUp,
1299
+ destinations: Array.from(perDestination.values()),
1300
+ };
1301
+ }
1302
+
1222
1303
  function applyGlobalSync(targetName, agentDir, timestamp, options) {
1223
1304
  if (targetName === "codex") {
1224
- const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ag-kit-global-codex-"));
1305
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ling-global-codex-"));
1225
1306
  const mockRoot = path.join(tempRoot, "source");
1226
1307
  const mockAgent = path.join(mockRoot, ".agents");
1227
1308
  const outputDir = path.join(tempRoot, "out");
@@ -1248,21 +1329,70 @@ async function commandGlobalSync(options) {
1248
1329
  const targets = await resolveTargetsForGlobalSync(options);
1249
1330
  const { agentDir, cleanup, sourceLabel } = resolveAgentInstallSource(options);
1250
1331
  const timestamp = nowISO().replace(/[:.]/g, "-");
1332
+ const prompter = createConflictPrompter(options);
1251
1333
 
1252
1334
  try {
1253
1335
  log(options, `[global] 全局同步源: ${sourceLabel}`);
1254
1336
  for (const target of targets) {
1255
1337
  log(options, `[sync] 正在同步全局目标 [${target}] ...`);
1256
- const result = applyGlobalSync(target, agentDir, timestamp, options);
1257
- if (!options.dryRun) {
1258
- log(options, `[summary] 全局同步完成 [${target}]:总计 ${result.total},新增/覆盖 ${result.synced},跳过 ${result.skipped},备份 ${result.backedUp}`);
1259
- for (const item of result.destinations) {
1260
- log(options, ` - ${item.targetName}: ${item.destRoot}(每目标 ${item.total} Skills)`);
1338
+ const plan = planGlobalSyncTasks(target, agentDir);
1339
+ try {
1340
+ for (const task of plan.tasks) {
1341
+ const exists = fs.existsSync(task.destDir);
1342
+ const equal = exists ? areDirectoriesEqual(task.srcDir, task.destDir) : false;
1343
+ if (exists && equal) {
1344
+ task.result = "skipped";
1345
+ continue;
1346
+ }
1347
+
1348
+ if (exists && !equal && prompter) {
1349
+ const action = await prompter.resolveConflict({
1350
+ category: `global:${task.destination.id}`,
1351
+ label: `全局 Skill ${task.destination.id}/${task.skillName}`,
1352
+ path: task.destDir,
1353
+ });
1354
+ task.action = action;
1355
+ } else if (exists && !equal && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
1356
+ // 非交互环境保持原有行为:备份后覆盖
1357
+ task.action = "backup";
1358
+ }
1359
+
1360
+ if (task.action === "keep") {
1361
+ task.result = "skipped";
1362
+ continue;
1363
+ }
1364
+
1365
+ if (options.dryRun) {
1366
+ log(options, `[dry-run] 将同步全局 Skill: ${task.destination.id}/${task.skillName}`);
1367
+ task.result = "skipped";
1368
+ continue;
1369
+ }
1370
+
1371
+ if (exists && task.action !== "remove") {
1372
+ backupSkillDirectory(task.destination.id, task.skillName, task.destDir, timestamp, options);
1373
+ task.backedUp = true;
1374
+ }
1375
+
1376
+ const logger = options.quiet ? (() => {}) : log.bind(null, options);
1377
+ AtomicWriter.atomicCopyDir(task.srcDir, task.destDir, { logger });
1378
+ log(options, `[ok] 已同步全局 Skill: ${task.destination.id}/${task.skillName}`);
1379
+ task.result = "synced";
1380
+ }
1381
+
1382
+ const summary = summarizeGlobalSync(plan.tasks);
1383
+ if (!options.dryRun) {
1384
+ log(options, `[summary] 全局同步完成 [${target}]:总计 ${summary.total},新增/覆盖 ${summary.synced},跳过 ${summary.skipped},备份 ${summary.backedUp}`);
1385
+ for (const item of summary.destinations) {
1386
+ log(options, ` - ${item.targetName}: ${item.destRoot}(每目标 ${item.total} 个 Skills)`);
1387
+ }
1261
1388
  }
1389
+ } finally {
1390
+ if (plan.cleanup) plan.cleanup();
1262
1391
  }
1263
1392
  }
1264
1393
  } finally {
1265
1394
  if (cleanup) cleanup();
1395
+ if (prompter) prompter.close();
1266
1396
  }
1267
1397
  }
1268
1398
 
@@ -1324,6 +1454,10 @@ function getSpecHomeDir() {
1324
1454
  return path.join(resolveGlobalRootDir(), ".ling", "spec");
1325
1455
  }
1326
1456
 
1457
+ function getSpecWorkspaceDir() {
1458
+ return path.join(resolveGlobalRootDir(), ".ling", "spec-workspace");
1459
+ }
1460
+
1327
1461
  function getSpecStatePath() {
1328
1462
  return path.join(getSpecHomeDir(), "state.json");
1329
1463
  }
@@ -1401,6 +1535,46 @@ function ensureBundledSpecResources() {
1401
1535
  }
1402
1536
  }
1403
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
+
1404
1578
  function backupDirSnapshot(sourceDir, backupDir, options, label) {
1405
1579
  if (!fs.existsSync(sourceDir)) {
1406
1580
  return "";
@@ -1437,7 +1611,60 @@ function removeDirIfExists(targetDir, options, label) {
1437
1611
  log(options, `[clean] 已删除 ${label}: ${targetDir}`);
1438
1612
  }
1439
1613
 
1440
- function ensureSpecAssetsInstalled(state, timestamp, options) {
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
+
1667
+ async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) {
1441
1668
  const specHome = getSpecHomeDir();
1442
1669
  const assets = {
1443
1670
  templates: {
@@ -1448,32 +1675,102 @@ function ensureSpecAssetsInstalled(state, timestamp, options) {
1448
1675
  sourceDir: path.join(BUNDLED_SPEC_DIR, "references"),
1449
1676
  destDir: path.join(specHome, "references"),
1450
1677
  },
1678
+ profiles: {
1679
+ sourceDir: path.join(BUNDLED_SPEC_DIR, "profiles"),
1680
+ destDir: path.join(specHome, "profiles"),
1681
+ },
1451
1682
  };
1452
1683
 
1453
1684
  for (const [assetName, config] of Object.entries(assets)) {
1454
- 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
+ }
1706
+ continue;
1707
+ }
1708
+
1709
+ let action = exists ? "backup" : "";
1710
+ if (exists && prompter) {
1711
+ action = await prompter.resolveConflict({
1712
+ category: "spec:assets",
1713
+ label: `Spec ${assetName}`,
1714
+ path: config.destDir,
1715
+ });
1716
+ } else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
1717
+ action = "backup";
1718
+ }
1719
+
1720
+ if (action === "keep") {
1721
+ log(options, `[skip] 已保留 Spec ${assetName} 资产,不覆盖: ${config.destDir}`);
1722
+ state.assets[assetName] = {
1723
+ destPath: config.destDir,
1724
+ backupPath: "",
1725
+ installedAt: nowISO(),
1726
+ mode: "kept",
1727
+ };
1455
1728
  continue;
1456
1729
  }
1457
1730
 
1458
- const backupPath = backupDirSnapshot(
1459
- config.destDir,
1460
- path.join(resolveSpecBackupRoot(timestamp), "assets", assetName),
1461
- options,
1462
- `Spec ${assetName}`,
1463
- );
1731
+ let backupPath = "";
1732
+ if (exists && action !== "remove") {
1733
+ backupPath = backupDirSnapshot(
1734
+ config.destDir,
1735
+ path.join(resolveSpecBackupRoot(timestamp), "assets", assetName),
1736
+ options,
1737
+ `Spec ${assetName}`,
1738
+ );
1739
+ } else if (exists && action === "remove") {
1740
+ removeDirIfExists(config.destDir, options, `Spec ${assetName}`);
1741
+ }
1742
+
1464
1743
  applyDirSnapshot(config.sourceDir, config.destDir, options, `Spec ${assetName}`);
1465
1744
  state.assets[assetName] = {
1466
1745
  destPath: config.destDir,
1467
1746
  backupPath,
1468
1747
  installedAt: nowISO(),
1748
+ mode: exists ? (action === "remove" ? "replaced" : "backup") : "created",
1469
1749
  };
1470
1750
  }
1471
1751
  }
1472
1752
 
1753
+ function normalizeSpecAssetMode(assetState) {
1754
+ if (!assetState || typeof assetState !== "object") {
1755
+ return "";
1756
+ }
1757
+ if (typeof assetState.mode === "string" && assetState.mode) {
1758
+ return assetState.mode;
1759
+ }
1760
+ if (typeof assetState.backupPath === "string" && assetState.backupPath) {
1761
+ return "backup";
1762
+ }
1763
+ return "created";
1764
+ }
1765
+
1473
1766
  function restoreSpecAsset(assetState, options, label) {
1474
1767
  if (!assetState || typeof assetState.destPath !== "string") {
1475
1768
  return;
1476
1769
  }
1770
+ const mode = normalizeSpecAssetMode(assetState);
1771
+ if (mode === "kept") {
1772
+ return;
1773
+ }
1477
1774
  if (assetState.backupPath && fs.existsSync(assetState.backupPath)) {
1478
1775
  applyDirSnapshot(assetState.backupPath, assetState.destPath, options, label);
1479
1776
  return;
@@ -1481,37 +1778,100 @@ function restoreSpecAsset(assetState, options, label) {
1481
1778
  removeDirIfExists(assetState.destPath, options, label);
1482
1779
  }
1483
1780
 
1484
- function enableSpecTarget(targetName, state, timestamp, options) {
1485
- if (state.targets[targetName]) {
1486
- log(options, `[skip] Spec 目标已启用: ${targetName}`);
1487
- return;
1488
- }
1489
-
1781
+ async function enableSpecTarget(targetName, state, timestamp, options, prompter) {
1490
1782
  const destinations = getGlobalDestinations(targetName);
1491
- const targetState = {
1783
+ const existingTargetState = state.targets[targetName];
1784
+ if (existingTargetState) {
1785
+ log(options, `[info] Spec 目标已启用,执行一致性修复: ${targetName}`);
1786
+ }
1787
+ const targetState = existingTargetState || {
1492
1788
  enabledAt: nowISO(),
1493
1789
  consumers: {},
1494
1790
  };
1495
1791
 
1496
1792
  for (const destination of destinations) {
1793
+ const existingConsumerState =
1794
+ targetState.consumers && targetState.consumers[destination.id] ? targetState.consumers[destination.id] : null;
1497
1795
  const consumerState = {
1498
1796
  skills: [],
1499
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
+ }
1500
1806
 
1501
1807
  for (const skillName of SPEC_SKILL_NAMES) {
1502
1808
  const srcDir = path.join(BUNDLED_SPEC_DIR, "skills", skillName);
1503
1809
  const destDir = path.join(destination.skillsRoot, skillName);
1504
- const backupPath = backupDirSnapshot(
1505
- destDir,
1506
- path.join(resolveSpecBackupRoot(timestamp), destination.id, "skills", skillName),
1507
- options,
1508
- `Spec Skill ${destination.id}/${skillName}`,
1509
- );
1810
+ const existingSkillState = existingSkills.get(skillName);
1811
+ const mode = normalizeSpecAssetMode(existingSkillState);
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
+
1835
+ let action = exists ? "backup" : "";
1836
+ if (exists && prompter) {
1837
+ action = await prompter.resolveConflict({
1838
+ category: `spec:skills:${destination.id}`,
1839
+ label: `Spec Skill ${destination.id}/${skillName}`,
1840
+ path: destDir,
1841
+ });
1842
+ } else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
1843
+ action = "backup";
1844
+ }
1845
+
1846
+ if (action === "keep") {
1847
+ log(options, `[skip] 已保留 Spec Skill,不覆盖: ${destination.id}/${skillName}`);
1848
+ consumerState.skills.push({
1849
+ name: skillName,
1850
+ destPath: destDir,
1851
+ backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
1852
+ mode: "kept",
1853
+ });
1854
+ continue;
1855
+ }
1856
+
1857
+ let backupPath = existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "";
1858
+ if (exists && action !== "remove") {
1859
+ backupPath = backupDirSnapshot(
1860
+ destDir,
1861
+ path.join(resolveSpecBackupRoot(timestamp), destination.id, "skills", skillName),
1862
+ options,
1863
+ `Spec Skill ${destination.id}/${skillName}`,
1864
+ );
1865
+ } else if (exists && action === "remove") {
1866
+ removeDirIfExists(destDir, options, `Spec Skill ${destination.id}/${skillName}`);
1867
+ }
1868
+
1510
1869
  applyDirSnapshot(srcDir, destDir, options, `Spec Skill ${destination.id}/${skillName}`);
1511
1870
  consumerState.skills.push({
1512
1871
  name: skillName,
1513
1872
  destPath: destDir,
1514
1873
  backupPath,
1874
+ mode: exists ? (action === "remove" ? "replaced" : "backup") : "created",
1515
1875
  });
1516
1876
  }
1517
1877
 
@@ -1530,6 +1890,10 @@ function disableSpecTarget(targetName, state, options) {
1530
1890
 
1531
1891
  for (const [consumerId, consumerState] of Object.entries(targetState.consumers || {})) {
1532
1892
  for (const skill of consumerState.skills || []) {
1893
+ const mode = normalizeSpecAssetMode(skill);
1894
+ if (mode === "kept") {
1895
+ continue;
1896
+ }
1533
1897
  if (skill.backupPath && fs.existsSync(skill.backupPath)) {
1534
1898
  applyDirSnapshot(skill.backupPath, skill.destPath, options, `恢复 Spec Skill ${consumerId}/${skill.name}`);
1535
1899
  } else {
@@ -1542,14 +1906,28 @@ function disableSpecTarget(targetName, state, options) {
1542
1906
  }
1543
1907
 
1544
1908
  function evaluateSpecState() {
1909
+ const statePath = getSpecStatePath();
1910
+ const hasStateFile = fs.existsSync(statePath);
1545
1911
  const { state } = readSpecState();
1546
1912
  const targetNames = Object.keys(state.targets || {});
1547
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
+ }
1548
1925
  return {
1549
1926
  state: "missing",
1550
1927
  targets: [],
1551
1928
  assets: state.assets || {},
1552
- specHome: getSpecHomeDir(),
1929
+ specHome,
1930
+ issues: [],
1553
1931
  };
1554
1932
  }
1555
1933
 
@@ -1565,10 +1943,23 @@ function evaluateSpecState() {
1565
1943
  }
1566
1944
  }
1567
1945
 
1568
- 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"]) {
1569
1954
  const asset = state.assets[assetName];
1570
1955
  if (!asset || !asset.destPath || !fs.existsSync(asset.destPath)) {
1571
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}`);
1572
1963
  }
1573
1964
  }
1574
1965
 
@@ -1577,7 +1968,7 @@ function evaluateSpecState() {
1577
1968
  targets: targetNames,
1578
1969
  issues,
1579
1970
  assets: state.assets || {},
1580
- specHome: getSpecHomeDir(),
1971
+ specHome,
1581
1972
  };
1582
1973
  }
1583
1974
 
@@ -1608,15 +1999,308 @@ function commandSpecStatus(options) {
1608
1999
  setQuietStatusExitCode(summary.state);
1609
2000
  }
1610
2001
 
1611
- function commandSpecEnable(options) {
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
+
2290
+ async function commandSpecEnable(options) {
1612
2291
  ensureBundledSpecResources();
1613
2292
  const targets = resolveTargetsForSpec(options);
1614
2293
  const { statePath, state } = readSpecState();
1615
2294
  const timestamp = nowISO().replace(/[:.]/g, "-");
2295
+ const prompter = createConflictPrompter(options);
1616
2296
 
1617
- ensureSpecAssetsInstalled(state, timestamp, options);
1618
- for (const targetName of targets) {
1619
- enableSpecTarget(targetName, state, timestamp, options);
2297
+ try {
2298
+ await ensureSpecAssetsInstalled(state, timestamp, options, prompter);
2299
+ for (const targetName of targets) {
2300
+ await enableSpecTarget(targetName, state, timestamp, options, prompter);
2301
+ }
2302
+ } finally {
2303
+ if (prompter) prompter.close();
1620
2304
  }
1621
2305
 
1622
2306
  state.updatedAt = nowISO();
@@ -1639,6 +2323,7 @@ function commandSpecDisable(options) {
1639
2323
  if (remainingTargets.length === 0) {
1640
2324
  restoreSpecAsset(state.assets.templates, options, "Spec templates");
1641
2325
  restoreSpecAsset(state.assets.references, options, "Spec references");
2326
+ restoreSpecAsset(state.assets.profiles, options, "Spec profiles");
1642
2327
  state.assets = {};
1643
2328
  }
1644
2329
 
@@ -1646,6 +2331,14 @@ function commandSpecDisable(options) {
1646
2331
  if (!options.dryRun) {
1647
2332
  if (remainingTargets.length === 0) {
1648
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
+ }
1649
2342
  } else {
1650
2343
  writeSpecState(statePath, state);
1651
2344
  }
@@ -1654,29 +2347,110 @@ function commandSpecDisable(options) {
1654
2347
  log(options, `[ok] Spec Profile 已停用 (Targets: ${targets.join(", ")})`);
1655
2348
  }
1656
2349
 
1657
- function commandSpec(options) {
2350
+ async function commandSpec(options) {
1658
2351
  const subcommand = String(options.subcommand || "status").toLowerCase();
1659
2352
  if (subcommand === "status") {
1660
2353
  return commandSpecStatus(options);
1661
2354
  }
1662
2355
  if (subcommand === "enable") {
1663
- return commandSpecEnable(options);
2356
+ return await commandSpecEnable(options);
1664
2357
  }
1665
2358
  if (subcommand === "disable") {
1666
2359
  return commandSpecDisable(options);
1667
2360
  }
2361
+ if (subcommand === "init") {
2362
+ return await commandSpecInit(options);
2363
+ }
2364
+ if (subcommand === "doctor") {
2365
+ return commandSpecDoctor(options);
2366
+ }
1668
2367
  throw new Error(`未知 spec 子命令: ${subcommand}`);
1669
2368
  }
1670
2369
 
2370
+ async function initTargets(workspaceRoot, targets, options, prompter) {
2371
+ for (const target of targets) {
2372
+ const runOptions = { ...options };
2373
+ const conflicts = [];
2374
+
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
+ }
2386
+
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)) {
2392
+ conflicts.push({
2393
+ category: "project:codex",
2394
+ label: ".agents",
2395
+ path: managedDir,
2396
+ target,
2397
+ });
2398
+ }
2399
+ if (fs.existsSync(legacyDir)) {
2400
+ conflicts.push({
2401
+ category: "project:codex",
2402
+ label: ".codex",
2403
+ path: legacyDir,
2404
+ target,
2405
+ });
2406
+ }
2407
+ }
2408
+ }
2409
+
2410
+ if (conflicts.length > 0) {
2411
+ if (!prompter && !runOptions.force) {
2412
+ throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。");
2413
+ }
2414
+
2415
+ const timestamp = nowISO().replace(/[:.]/g, "-");
2416
+ let shouldSkip = false;
2417
+
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;
2424
+ }
2425
+ if (action === "backup") {
2426
+ const backupRootName = conflict.label === ".agent" ? ".agent-backup" : ".agents-backup";
2427
+ backupWorkspaceDir(workspaceRoot, conflict.path, backupRootName, timestamp, options, `工作区资产 ${conflict.label}`);
2428
+ }
2429
+ // remove/backup 都需要强制覆盖才能继续
2430
+ runOptions.force = true;
2431
+ }
2432
+
2433
+ if (shouldSkip) {
2434
+ continue;
2435
+ }
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
+
1671
2445
  async function commandInit(options) {
1672
2446
  const workspaceRoot = resolveWorkspaceRoot(options.path);
1673
2447
  const targets = await resolveTargetsForInit(options);
2448
+ const prompter = createConflictPrompter(options);
1674
2449
 
1675
- for (const target of targets) {
1676
- const adapter = createAdapter(target, workspaceRoot, options);
1677
- log(options, `[sync] 正在初始化目标 [${target}] ...`);
1678
- adapter.install(BUNDLED_AGENT_DIR);
1679
- registerWorkspaceTarget(workspaceRoot, target, options);
2450
+ try {
2451
+ await initTargets(workspaceRoot, targets, options, prompter);
2452
+ } finally {
2453
+ if (prompter) prompter.close();
1680
2454
  }
1681
2455
 
1682
2456
  if (targets.length > 0) {
@@ -1684,9 +2458,62 @@ async function commandInit(options) {
1684
2458
  }
1685
2459
  }
1686
2460
 
2461
+ function evaluateCodexUpdateConflict(activeDir) {
2462
+ const manifestPath = path.join(activeDir, "manifest.json");
2463
+ const details = {
2464
+ hasConflict: false,
2465
+ manifestMissing: false,
2466
+ modifiedCount: 0,
2467
+ unknownCount: 0,
2468
+ };
2469
+
2470
+ if (!fs.existsSync(manifestPath)) {
2471
+ details.hasConflict = true;
2472
+ details.manifestMissing = true;
2473
+ return details;
2474
+ }
2475
+
2476
+ try {
2477
+ const manager = new ManifestManager(manifestPath, { target: "codex" });
2478
+ manager.load();
2479
+ const drift = manager.checkDrift(activeDir);
2480
+ if (drift.modified.length > 0) {
2481
+ details.hasConflict = true;
2482
+ details.modifiedCount = drift.modified.length;
2483
+ }
2484
+
2485
+ const manifestFiles = manager.manifest.files || {};
2486
+ const stack = [activeDir];
2487
+ while (stack.length > 0) {
2488
+ const dir = stack.pop();
2489
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2490
+ for (const entry of entries) {
2491
+ const fullPath = path.join(dir, entry.name);
2492
+ if (entry.isDirectory()) {
2493
+ stack.push(fullPath);
2494
+ continue;
2495
+ }
2496
+ const relPath = path.relative(activeDir, fullPath).split(path.sep).join("/");
2497
+ if (!manifestFiles[relPath]) {
2498
+ details.unknownCount += 1;
2499
+ }
2500
+ }
2501
+ }
2502
+ if (details.unknownCount > 0) {
2503
+ details.hasConflict = true;
2504
+ }
2505
+ } catch (err) {
2506
+ details.hasConflict = true;
2507
+ details.manifestMissing = true;
2508
+ }
2509
+
2510
+ return details;
2511
+ }
2512
+
1687
2513
  async function commandUpdate(options) {
1688
2514
  const workspaceRoot = resolveWorkspaceRoot(options.path);
1689
2515
  const targets = resolveTargetsForUpdate(workspaceRoot, options);
2516
+ const prompter = createConflictPrompter(options);
1690
2517
 
1691
2518
  if (targets.length === 0) {
1692
2519
  throw new Error(`此目录未检测到 ${PRIMARY_CLI_NAME} 安装,无法更新。请先执行 init。`);
@@ -1695,7 +2522,8 @@ async function commandUpdate(options) {
1695
2522
  log(options, `[update] 正在更新 Ling (Targets: ${targets.join(", ")})...`);
1696
2523
 
1697
2524
  let updatedAny = false;
1698
- for (const target of targets) {
2525
+ try {
2526
+ for (const target of targets) {
1699
2527
  if (!isTargetInstalled(workspaceRoot, target) && options.targets.length > 0) {
1700
2528
  throw new Error(`目标未安装: ${target}`);
1701
2529
  }
@@ -1705,15 +2533,69 @@ async function commandUpdate(options) {
1705
2533
  }
1706
2534
 
1707
2535
  const runOptions = { ...options, force: true };
2536
+
2537
+ const timestamp = nowISO().replace(/[:.]/g, "-");
2538
+ if (target === "gemini") {
2539
+ const agentDir = path.join(workspaceRoot, ".agent");
2540
+ if (fs.existsSync(agentDir) && !areDirectoriesEqual(BUNDLED_AGENT_DIR, agentDir)) {
2541
+ let action = "backup";
2542
+ if (prompter) {
2543
+ action = await prompter.resolveConflict({
2544
+ category: "project:gemini",
2545
+ label: ".agent",
2546
+ path: agentDir,
2547
+ target,
2548
+ });
2549
+ }
2550
+ if (action === "keep") {
2551
+ log(options, "[skip] 已保留现有资产,跳过更新: .agent");
2552
+ continue;
2553
+ }
2554
+ if (action === "backup") {
2555
+ backupWorkspaceDir(workspaceRoot, agentDir, ".agent-backup", timestamp, options, "工作区资产 .agent");
2556
+ }
2557
+ }
2558
+ }
2559
+
2560
+ if (target === "codex") {
2561
+ const managedDir = path.join(workspaceRoot, ".agents");
2562
+ const legacyDir = path.join(workspaceRoot, ".codex");
2563
+ const activeDir = fs.existsSync(managedDir) ? managedDir : legacyDir;
2564
+ const conflict = evaluateCodexUpdateConflict(activeDir);
2565
+ if (conflict.hasConflict) {
2566
+ let action = "backup";
2567
+ if (prompter) {
2568
+ action = await prompter.resolveConflict({
2569
+ category: "project:codex",
2570
+ label: fs.existsSync(managedDir) ? ".agents" : ".codex",
2571
+ path: activeDir,
2572
+ target,
2573
+ });
2574
+ }
2575
+ if (action === "keep") {
2576
+ log(options, `[skip] 已保留现有资产,跳过更新: ${path.basename(activeDir)}`);
2577
+ continue;
2578
+ }
2579
+ if (action === "backup") {
2580
+ const backupRootName = ".agents-backup";
2581
+ backupWorkspaceDir(workspaceRoot, activeDir, backupRootName, timestamp, options, `工作区资产 ${path.basename(activeDir)}`);
2582
+ }
2583
+ }
2584
+ }
2585
+
1708
2586
  const adapter = createAdapter(target, workspaceRoot, runOptions);
1709
2587
  log(options, `[sync] 更新 [${target}] ...`);
1710
2588
  adapter.update(BUNDLED_AGENT_DIR);
1711
2589
  registerWorkspaceTarget(workspaceRoot, target, runOptions);
1712
2590
  updatedAny = true;
2591
+ }
2592
+ } finally {
2593
+ if (prompter) prompter.close();
1713
2594
  }
1714
2595
 
1715
2596
  if (!updatedAny) {
1716
- throw new Error("未找到可更新的目标");
2597
+ log(options, "[skip] 未执行更新(已保留现有资产)");
2598
+ return;
1717
2599
  }
1718
2600
  }
1719
2601
 
@@ -1754,6 +2636,7 @@ async function commandUpdateAll(options) {
1754
2636
 
1755
2637
  log(options, `[update] 开始批量更新工作区(共 ${records.length} 个)...`);
1756
2638
  log(options, `[index] 索引文件: ${indexPath}`);
2639
+ const prompter = createConflictPrompter(options);
1757
2640
 
1758
2641
  let updated = 0;
1759
2642
  let skipped = 0;
@@ -1764,7 +2647,8 @@ async function commandUpdateAll(options) {
1764
2647
  const nextRecords = [];
1765
2648
  const removedRecordKeys = new Set();
1766
2649
 
1767
- for (let i = 0; i < records.length; i++) {
2650
+ try {
2651
+ for (let i = 0; i < records.length; i++) {
1768
2652
  const item = normalizeWorkspaceRecordV2(records[i], normalizeAbsolutePath(records[i].path));
1769
2653
  const workspacePath = normalizeAbsolutePath(item.path);
1770
2654
  const exclusion = evaluateWorkspaceExclusion(index, workspacePath);
@@ -1825,6 +2709,54 @@ async function commandUpdateAll(options) {
1825
2709
  path: workspacePath,
1826
2710
  silentIndexLog: true,
1827
2711
  };
2712
+
2713
+ const timestampForBackup = nowISO().replace(/[:.]/g, "-");
2714
+
2715
+ if (target === "gemini") {
2716
+ const agentDir = path.join(workspacePath, ".agent");
2717
+ if (fs.existsSync(agentDir) && !areDirectoriesEqual(BUNDLED_AGENT_DIR, agentDir)) {
2718
+ let action = "backup";
2719
+ if (prompter) {
2720
+ action = await prompter.resolveConflict({
2721
+ category: "update-all:project:gemini",
2722
+ label: `.agent (${workspacePath})`,
2723
+ path: agentDir,
2724
+ });
2725
+ }
2726
+ if (action === "keep") {
2727
+ log(options, `[skip] [${i + 1}/${records.length}] 已保留现有资产,跳过更新: ${workspacePath} [gemini]`);
2728
+ continue;
2729
+ }
2730
+ if (action === "backup") {
2731
+ backupWorkspaceDir(workspacePath, agentDir, ".agent-backup", timestampForBackup, options, `工作区资产 .agent (${workspacePath})`);
2732
+ }
2733
+ }
2734
+ }
2735
+
2736
+ if (target === "codex") {
2737
+ const managedDir = path.join(workspacePath, ".agents");
2738
+ const legacyDir = path.join(workspacePath, ".codex");
2739
+ const activeDir = fs.existsSync(managedDir) ? managedDir : legacyDir;
2740
+ const conflict = evaluateCodexUpdateConflict(activeDir);
2741
+ if (conflict.hasConflict) {
2742
+ let action = "backup";
2743
+ if (prompter) {
2744
+ action = await prompter.resolveConflict({
2745
+ category: "update-all:project:codex",
2746
+ label: `${path.basename(activeDir)} (${workspacePath})`,
2747
+ path: activeDir,
2748
+ });
2749
+ }
2750
+ if (action === "keep") {
2751
+ log(options, `[skip] [${i + 1}/${records.length}] 已保留现有资产,跳过更新: ${workspacePath} [codex]`);
2752
+ continue;
2753
+ }
2754
+ if (action === "backup") {
2755
+ backupWorkspaceDir(workspacePath, activeDir, ".agents-backup", timestampForBackup, options, `工作区资产 ${path.basename(activeDir)} (${workspacePath})`);
2756
+ }
2757
+ }
2758
+ }
2759
+
1828
2760
  const adapter = createAdapter(target, workspacePath, runOptions);
1829
2761
  adapter.update(BUNDLED_AGENT_DIR);
1830
2762
  updatedTargets.push(target);
@@ -1844,44 +2776,49 @@ async function commandUpdateAll(options) {
1844
2776
  skipped += 1;
1845
2777
  nextRecords.push(item);
1846
2778
  }
1847
- }
2779
+ }
1848
2780
 
1849
- if (!options.dryRun) {
1850
- withWorkspaceIndexLock(indexPath, () => {
1851
- const { index: latestIndex } = readWorkspaceIndex();
1852
- const mergedMap = new Map();
2781
+ if (!options.dryRun) {
2782
+ withWorkspaceIndexLock(indexPath, () => {
2783
+ const { index: latestIndex } = readWorkspaceIndex();
2784
+ const mergedMap = new Map();
1853
2785
 
1854
- for (const item of latestIndex.workspaces || []) {
1855
- if (!item || typeof item.path !== "string") continue;
1856
- mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
1857
- }
2786
+ for (const item of latestIndex.workspaces || []) {
2787
+ if (!item || typeof item.path !== "string") continue;
2788
+ mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
2789
+ }
1858
2790
 
1859
- for (const removedKey of removedRecordKeys) {
1860
- mergedMap.delete(removedKey);
1861
- }
2791
+ for (const removedKey of removedRecordKeys) {
2792
+ mergedMap.delete(removedKey);
2793
+ }
1862
2794
 
1863
- for (const item of nextRecords) {
1864
- if (!item || typeof item.path !== "string") continue;
1865
- mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
1866
- }
2795
+ for (const item of nextRecords) {
2796
+ if (!item || typeof item.path !== "string") continue;
2797
+ mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
2798
+ }
1867
2799
 
1868
- latestIndex.workspaces = Array.from(mergedMap.values()).sort((a, b) => a.path.localeCompare(b.path));
1869
- latestIndex.updatedAt = timestamp;
1870
- writeWorkspaceIndex(indexPath, latestIndex);
1871
- });
1872
- }
2800
+ latestIndex.workspaces = Array.from(mergedMap.values()).sort((a, b) => a.path.localeCompare(b.path));
2801
+ latestIndex.updatedAt = timestamp;
2802
+ writeWorkspaceIndex(indexPath, latestIndex);
2803
+ });
2804
+ }
1873
2805
 
1874
- log(options, "[summary] 批量更新完成");
1875
- log(options, ` 成功: ${updated}`);
1876
- log(options, ` 跳过: ${skipped}`);
1877
- log(options, ` 失败: ${failed}`);
1878
- log(options, ` 清理排除路径: ${removedExcluded}`);
1879
- if (options.pruneMissing) {
1880
- log(options, ` 清理失效索引: ${removedMissing}`);
1881
- }
2806
+ log(options, "[summary] 批量更新完成");
2807
+ log(options, ` 成功: ${updated}`);
2808
+ log(options, ` 跳过: ${skipped}`);
2809
+ log(options, ` 失败: ${failed}`);
2810
+ log(options, ` 清理排除路径: ${removedExcluded}`);
2811
+ if (options.pruneMissing) {
2812
+ log(options, ` 清理失效索引: ${removedMissing}`);
2813
+ }
1882
2814
 
1883
- if (failed > 0) {
1884
- process.exitCode = 1;
2815
+ if (failed > 0) {
2816
+ process.exitCode = 1;
2817
+ }
2818
+ } finally {
2819
+ if (prompter) {
2820
+ prompter.close();
2821
+ }
1885
2822
  }
1886
2823
  }
1887
2824
 
@@ -1946,6 +2883,22 @@ async function commandDoctor(options) {
1946
2883
  }
1947
2884
  }
1948
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
+
1949
2902
  if (hasIssue) {
1950
2903
  process.exitCode = 1;
1951
2904
  }
@@ -2191,14 +3144,8 @@ function commandStatus(options) {
2191
3144
 
2192
3145
  async function main() {
2193
3146
  try {
2194
- const migration = migrateLegacyControlHomeDir();
2195
3147
  const { command, options, providedFlags } = parseArgs(process.argv.slice(2));
2196
3148
 
2197
- if (migration && !options.quiet) {
2198
- console.log(`[migrate] 已迁移控制目录: ${migration.from} -> ${migration.to}`);
2199
- }
2200
- maybeWarnLegacyCliAlias(options);
2201
-
2202
3149
  if (!command || command === "--help" || command === "-h") {
2203
3150
  printUsage();
2204
3151
  if (!command || command === "--help" || command === "-h") {
@@ -2240,7 +3187,7 @@ async function main() {
2240
3187
  }
2241
3188
 
2242
3189
  if (command === "spec") {
2243
- commandSpec(options);
3190
+ await commandSpec(options);
2244
3191
  return;
2245
3192
  }
2246
3193