diff --git a/.agents/rules/GEMINI.md b/.agents/rules/GEMINI.md index 5060743..3414e6b 100644 --- a/.agents/rules/GEMINI.md +++ b/.agents/rules/GEMINI.md @@ -27,6 +27,23 @@ trigger: always_on --- +## Spec 协议(任务跟踪 CSV 驱动) + +当工作区存在 `issues.csv` 时,视其为任务跟踪文件(单一事实源),并遵循: + +1. 读取 `issues.csv` 并只锁定一个原子任务。 +2. 将任务状态从“未开始”改为“进行中”,再开始开发。 +3. 修改完成后运行最小充分验证,并记录证据。 +4. 自审通过后将状态改为“已完成”。 +5. 若验证失败,先修复或回退,不得静默降级。 + +模板参考: + +- 优先:`.ling/spec/templates/driver-prompt.md` +- 其次:`~/.ling/spec/templates/driver-prompt.md`(若已启用全局 Spec Profile) + +--- + ## 请求分类器(第 1 步) **在执行任何动作前,先对请求分类:** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ff950a..56a1130 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI on: push: - branches: [main] pull_request: jobs: @@ -20,20 +19,16 @@ jobs: uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - - - name: Setup Bun - run: | - npm install --global bun@1.3.9 - bun --version + cache: npm - name: Install dependencies - run: bun install + run: npm ci - name: Run tests - run: bun run test + run: npm test - name: CI end-to-end verify - run: bun run ci:verify + run: npm run ci:verify - name: Health check - run: bun run health-check + run: npm run health-check diff --git a/CHANGELOG.md b/CHANGELOG.md index 2063379..b7afa14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ ## [Unreleased] +## [ling-1.1.1] - 2026-03-14 + +### 新增 + +- Spec 项目级工作区初始化与诊断:新增 `ling spec init` / `ling spec doctor`,在工作区落盘 `.ling/spec` 资产与 `issues.csv` 校验。 +- Spec Profiles 资产:全局与项目级均包含 `profiles/`,并纳入完整性校验与回退语义。 +- Spec 资产分支拉取:`ling spec init --branch ` 支持从指定分支的 `.spec/` 拉取 templates/references/profiles。 + +### 变更 + +- Spec 状态判定强化:`ling spec status` 增加文件级完整性校验,且在 `state.json` 缺失但检测到残留 artifacts 时返回 `broken` 并给出修复提示。 +- CI 改为 npm:CI 矩阵不再依赖 bun,统一使用 `npm ci/test/run` 执行验证。 + ## [ling-1.1.0] - 2026-03-13 ### 新增 @@ -67,7 +80,8 @@ 本项目在 Ling 重启前的 2.x/3.x 版本记录已冻结,不再维护。 -[Unreleased]: https://github.com/MisonL/Ling/compare/ling-1.1.0...HEAD +[Unreleased]: https://github.com/MisonL/Ling/compare/ling-1.1.1...HEAD +[ling-1.1.1]: https://github.com/MisonL/Ling/releases/tag/ling-1.1.1 [ling-1.1.0]: https://github.com/MisonL/Ling/releases/tag/ling-1.1.0 [ling-1.0.2]: https://github.com/MisonL/Ling/releases/tag/ling-1.0.2 [ling-1.0.1]: https://github.com/MisonL/Ling/releases/tag/ling-1.0.1 diff --git a/bin/core/generator.js b/bin/core/generator.js index 15cc349..536e716 100644 --- a/bin/core/generator.js +++ b/bin/core/generator.js @@ -37,6 +37,7 @@ class RuleGenerator { agentsMd += `1. Managed resources are synchronized under \`.agents/skills\`.\n`; agentsMd += `2. Do not rename managed skill folders manually.\n`; agentsMd += `3. Use \`ling doctor --target codex --fix\` to recover missing managed artifacts.\n`; + agentsMd += `4. If \`issues.csv\` exists, treat it as the task tracking source of truth and keep at most one task in \`进行中\`.\n`; // 3. Generate risk controls let lingRules = `# Ling Risk Controls (Codex Managed)\n\n`; diff --git a/bin/ling-cli.js b/bin/ling-cli.js index 4c9271f..40a7f17 100755 --- a/bin/ling-cli.js +++ b/bin/ling-cli.js @@ -5,7 +5,7 @@ const os = require("os"); const path = require("path"); const pkg = require("../package.json"); -const { readGlobalNpmDependencies, cloneBranchAgentDir } = require("./utils"); +const { readGlobalNpmDependencies, cloneBranchAgentDir, cloneBranchSpecDir } = require("./utils"); const ManifestManager = require("./utils/manifest"); const AtomicWriter = require("./utils/atomic-writer"); const CodexBuilder = require("./core/builder"); @@ -55,6 +55,9 @@ const QUIET_STATUS_EXIT_CODES = { const SPEC_STATE_VERSION = 1; const SPEC_SKILL_NAMES = ["harness-engineering", "cybernetic-systems-engineering"]; const VERSION_TAG_PREFIX = "ling-"; +const SPEC_TEMPLATE_REQUIRED_FILES = ["issues.template.csv", "driver-prompt.md", "review-report.md", "phase-acceptance.md", "handoff.md"]; +const SPEC_REFERENCE_REQUIRED_FILES = ["README.md", "harness-engineering-digest.md", "gda-framework.md", "cse-quickstart.md"]; +const SPEC_PROFILE_REQUIRED_FILES = ["codex/AGENTS.spec.md", "codex/ling.spec.rules.md", "gemini/GEMINI.spec.md"]; function nowISO() { return new Date().toISOString(); @@ -172,6 +175,8 @@ function printUsage() { console.log(` ${PRIMARY_CLI_NAME} spec enable [--target |--targets ] [--quiet] [--dry-run]`); console.log(` ${PRIMARY_CLI_NAME} spec disable [--target |--targets ] [--quiet] [--dry-run]`); console.log(` ${PRIMARY_CLI_NAME} spec status [--quiet]`); + console.log(` ${PRIMARY_CLI_NAME} spec init [--path ] [--target |--targets ] [--branch ] [--force] [--non-interactive] [--no-index] [--quiet] [--dry-run]`); + console.log(` ${PRIMARY_CLI_NAME} spec doctor [--path ] [--quiet]`); console.log(` ${PRIMARY_CLI_NAME} exclude list [--quiet]`); console.log(` ${PRIMARY_CLI_NAME} exclude add --path [--dry-run] [--quiet]`); console.log(` ${PRIMARY_CLI_NAME} exclude remove --path [--dry-run] [--quiet]`); @@ -290,6 +295,8 @@ const COMMAND_ALLOWED_FLAGS = { "spec:enable": ["--target", "--targets", "--quiet", "--dry-run"], "spec:disable": ["--target", "--targets", "--quiet", "--dry-run"], "spec:status": ["--quiet"], + "spec:init": ["--force", "--path", "--branch", "--target", "--targets", "--non-interactive", "--no-index", "--quiet", "--dry-run"], + "spec:doctor": ["--path", "--quiet"], "exclude:list": ["--quiet"], "exclude:add": ["--path", "--dry-run", "--quiet"], "exclude:remove": ["--path", "--dry-run", "--quiet"], @@ -1447,6 +1454,10 @@ function getSpecHomeDir() { return path.join(resolveGlobalRootDir(), ".ling", "spec"); } +function getSpecWorkspaceDir() { + return path.join(resolveGlobalRootDir(), ".ling", "spec-workspace"); +} + function getSpecStatePath() { return path.join(getSpecHomeDir(), "state.json"); } @@ -1524,6 +1535,46 @@ function ensureBundledSpecResources() { } } +function listMissingFiles(rootDir, requiredRelPaths) { + if (!fs.existsSync(rootDir)) { + return requiredRelPaths.map((rel) => rel); + } + return requiredRelPaths.filter((rel) => !fs.existsSync(path.join(rootDir, ...rel.split("/")))); +} + +function collectSpecOrphanIssues(specHome, hasStateFile) { + const issues = []; + const templatesDir = path.join(specHome, "templates"); + const referencesDir = path.join(specHome, "references"); + const profilesDir = path.join(specHome, "profiles"); + + if (fs.existsSync(templatesDir)) issues.push("Detected spec templates directory"); + if (fs.existsSync(referencesDir)) issues.push("Detected spec references directory"); + if (fs.existsSync(profilesDir)) issues.push("Detected spec profiles directory"); + + for (const targetName of SUPPORTED_TARGETS) { + for (const destination of getGlobalDestinations(targetName)) { + for (const skillName of SPEC_SKILL_NAMES) { + if (fs.existsSync(path.join(destination.skillsRoot, skillName, "SKILL.md"))) { + issues.push(`Detected spec skill: ${destination.id}/${skillName}`); + } + } + } + } + + if (issues.length === 0) { + return []; + } + + if (!hasStateFile) { + issues.unshift("Spec artifacts detected but state.json missing (run: ling spec enable to repair)"); + } else { + issues.unshift("Spec artifacts detected but no targets enabled (run: ling spec enable to reconcile or clean manually)"); + } + + return issues; +} + function backupDirSnapshot(sourceDir, backupDir, options, label) { if (!fs.existsSync(sourceDir)) { return ""; @@ -1560,6 +1611,59 @@ function removeDirIfExists(targetDir, options, label) { log(options, `[clean] 已删除 ${label}: ${targetDir}`); } +function atomicWriteFile(targetPath, content, options, label) { + const targetDir = path.dirname(targetPath); + if (options.dryRun) { + log(options, `[dry-run] 将写入 ${label}: ${targetPath}`); + return; + } + fs.mkdirSync(targetDir, { recursive: true }); + const tempPath = `${targetPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + fs.writeFileSync(tempPath, content, "utf8"); + try { + if (fs.existsSync(targetPath)) { + const backupPath = `${targetPath}.bak-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`; + fs.renameSync(targetPath, backupPath); + try { + fs.renameSync(tempPath, targetPath); + } catch (err) { + try { + fs.renameSync(backupPath, targetPath); + } catch (restoreErr) { + throw new Error(`临界失败: 无法将新版本写入目标且无法恢复旧版本。旧版本位于 ${backupPath}。错误: ${err.message}`); + } + throw err; + } + try { + fs.rmSync(backupPath, { force: true }); + } catch (cleanupErr) { + log(options, `[warn] 无法清理备份文件 ${backupPath}: ${cleanupErr.message}`); + } + return; + } + fs.renameSync(tempPath, targetPath); + } catch (err) { + if (fs.existsSync(tempPath)) { + fs.rmSync(tempPath, { force: true }); + } + throw new Error(`原子写入失败: ${err.message}`); + } +} + +function backupFileSnapshot(sourcePath, backupPath, options, label) { + if (!fs.existsSync(sourcePath)) { + return ""; + } + if (options.dryRun) { + log(options, `[dry-run] 将备份 ${label}: ${sourcePath} -> ${backupPath}`); + return backupPath; + } + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + fs.copyFileSync(sourcePath, backupPath); + log(options, `[backup] 已备份 ${label}: ${sourcePath} -> ${backupPath}`); + return backupPath; +} + async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) { const specHome = getSpecHomeDir(); const assets = { @@ -1571,14 +1675,37 @@ async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) { sourceDir: path.join(BUNDLED_SPEC_DIR, "references"), destDir: path.join(specHome, "references"), }, + profiles: { + sourceDir: path.join(BUNDLED_SPEC_DIR, "profiles"), + destDir: path.join(specHome, "profiles"), + }, }; for (const [assetName, config] of Object.entries(assets)) { - if (state.assets[assetName] && state.assets[assetName].installedAt) { + const existingAssetState = state.assets[assetName]; + const hasState = existingAssetState && existingAssetState.installedAt; + const exists = fs.existsSync(config.destDir); + const mode = normalizeSpecAssetMode(existingAssetState); + + if (mode === "kept" && exists) { + continue; + } + + const equal = exists ? areDirectoriesEqual(config.sourceDir, config.destDir) : false; + + if (exists && equal) { + if (!hasState) { + log(options, `[skip] Spec ${assetName} 已存在且一致,视为已启用: ${config.destDir}`); + state.assets[assetName] = { + destPath: config.destDir, + backupPath: "", + installedAt: nowISO(), + mode: "kept", + }; + } continue; } - const exists = fs.existsSync(config.destDir); let action = exists ? "backup" : ""; if (exists && prompter) { action = await prompter.resolveConflict({ @@ -1586,6 +1713,8 @@ async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) { label: `Spec ${assetName}`, path: config.destDir, }); + } else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) { + action = "backup"; } if (action === "keep") { @@ -1650,26 +1779,59 @@ function restoreSpecAsset(assetState, options, label) { } async function enableSpecTarget(targetName, state, timestamp, options, prompter) { - if (state.targets[targetName]) { - log(options, `[skip] Spec 目标已启用: ${targetName}`); - return; - } - const destinations = getGlobalDestinations(targetName); - const targetState = { + const existingTargetState = state.targets[targetName]; + if (existingTargetState) { + log(options, `[info] Spec 目标已启用,执行一致性修复: ${targetName}`); + } + const targetState = existingTargetState || { enabledAt: nowISO(), consumers: {}, }; for (const destination of destinations) { + const existingConsumerState = + targetState.consumers && targetState.consumers[destination.id] ? targetState.consumers[destination.id] : null; const consumerState = { skills: [], }; + const existingSkills = new Map(); + if (existingConsumerState && Array.isArray(existingConsumerState.skills)) { + for (const skill of existingConsumerState.skills) { + if (skill && typeof skill.name === "string") { + existingSkills.set(skill.name, skill); + } + } + } for (const skillName of SPEC_SKILL_NAMES) { const srcDir = path.join(BUNDLED_SPEC_DIR, "skills", skillName); const destDir = path.join(destination.skillsRoot, skillName); + const existingSkillState = existingSkills.get(skillName); + const mode = normalizeSpecAssetMode(existingSkillState); const exists = fs.existsSync(destDir); + const equal = exists ? areDirectoriesEqual(srcDir, destDir) : false; + + if (mode === "kept" && exists) { + consumerState.skills.push({ + name: skillName, + destPath: destDir, + backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "", + mode: "kept", + }); + continue; + } + + if (exists && equal) { + consumerState.skills.push({ + name: skillName, + destPath: destDir, + backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "", + mode: existingSkillState && existingSkillState.mode ? existingSkillState.mode : "kept", + }); + continue; + } + let action = exists ? "backup" : ""; if (exists && prompter) { action = await prompter.resolveConflict({ @@ -1677,6 +1839,8 @@ async function enableSpecTarget(targetName, state, timestamp, options, prompter) label: `Spec Skill ${destination.id}/${skillName}`, path: destDir, }); + } else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) { + action = "backup"; } if (action === "keep") { @@ -1684,13 +1848,13 @@ async function enableSpecTarget(targetName, state, timestamp, options, prompter) consumerState.skills.push({ name: skillName, destPath: destDir, - backupPath: "", + backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "", mode: "kept", }); continue; } - let backupPath = ""; + let backupPath = existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : ""; if (exists && action !== "remove") { backupPath = backupDirSnapshot( destDir, @@ -1742,14 +1906,28 @@ function disableSpecTarget(targetName, state, options) { } function evaluateSpecState() { + const statePath = getSpecStatePath(); + const hasStateFile = fs.existsSync(statePath); const { state } = readSpecState(); const targetNames = Object.keys(state.targets || {}); if (targetNames.length === 0) { + const specHome = getSpecHomeDir(); + const orphanIssues = collectSpecOrphanIssues(specHome, hasStateFile); + if (orphanIssues.length > 0) { + return { + state: "broken", + targets: [], + assets: state.assets || {}, + specHome, + issues: orphanIssues, + }; + } return { state: "missing", targets: [], assets: state.assets || {}, - specHome: getSpecHomeDir(), + specHome, + issues: [], }; } @@ -1765,10 +1943,23 @@ function evaluateSpecState() { } } - for (const assetName of ["templates", "references"]) { + const specHome = getSpecHomeDir(); + const assetRequirements = { + templates: { dir: path.join(specHome, "templates"), files: SPEC_TEMPLATE_REQUIRED_FILES }, + references: { dir: path.join(specHome, "references"), files: SPEC_REFERENCE_REQUIRED_FILES }, + profiles: { dir: path.join(specHome, "profiles"), files: SPEC_PROFILE_REQUIRED_FILES }, + }; + + for (const assetName of ["templates", "references", "profiles"]) { const asset = state.assets[assetName]; if (!asset || !asset.destPath || !fs.existsSync(asset.destPath)) { issues.push(`Missing spec asset: ${assetName}`); + continue; + } + const requirement = assetRequirements[assetName]; + const missing = listMissingFiles(requirement.dir, requirement.files); + for (const rel of missing) { + issues.push(`Missing spec asset file: ${assetName}/${rel}`); } } @@ -1777,7 +1968,7 @@ function evaluateSpecState() { targets: targetNames, issues, assets: state.assets || {}, - specHome: getSpecHomeDir(), + specHome, }; } @@ -1808,6 +1999,294 @@ function commandSpecStatus(options) { setQuietStatusExitCode(summary.state); } +function stripUtf8Bom(text) { + if (!text) return ""; + return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; +} + +function parseCsvLine(line) { + const cells = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === "\"") { + if (inQuotes && line[i + 1] === "\"") { + current += "\""; + i += 1; + continue; + } + inQuotes = !inQuotes; + continue; + } + if (ch === "," && !inQuotes) { + cells.push(current); + current = ""; + continue; + } + current += ch; + } + cells.push(current); + return cells; +} + +function analyzeIssuesCsv(issuesPath) { + if (!fs.existsSync(issuesPath)) { + return { status: "missing", issues: ["Missing issues.csv"], stats: { total: 0, inProgress: 0 } }; + } + + const raw = stripUtf8Bom(fs.readFileSync(issuesPath, "utf8")); + const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (lines.length === 0) { + return { status: "broken", issues: ["issues.csv is empty"], stats: { total: 0, inProgress: 0 } }; + } + + const header = parseCsvLine(lines[0]).map((cell) => cell.trim()); + const statusIndex = header.findIndex((cell) => cell === "状态" || cell.includes("(状态)") || /状态/.test(cell)); + if (statusIndex < 0) { + return { status: "broken", issues: ["issues.csv header missing 状态 column"], stats: { total: Math.max(0, lines.length - 1), inProgress: 0 } }; + } + + const allowedStates = new Set(["未开始", "进行中", "已完成"]); + let total = 0; + let inProgress = 0; + const issues = []; + + for (let i = 1; i < lines.length; i++) { + const row = parseCsvLine(lines[i]); + const isEmpty = row.every((cell) => String(cell || "").trim() === ""); + if (isEmpty) { + continue; + } + total += 1; + const state = String(row[statusIndex] || "").trim(); + if (!allowedStates.has(state)) { + issues.push(`Invalid 状态 at row ${i + 1}: ${state || "(empty)"}`); + continue; + } + if (state === "进行中") { + inProgress += 1; + } + } + + if (inProgress > 1) { + issues.push(`Multiple tasks in 进行中: ${inProgress}`); + } + + return { + status: issues.length > 0 ? "broken" : "ok", + issues, + stats: { total, inProgress }, + }; +} + +function checkSpecProjectIntegrity(workspaceRoot) { + const issues = []; + const issuesPath = path.join(workspaceRoot, "issues.csv"); + const issuesResult = analyzeIssuesCsv(issuesPath); + + const specDir = path.join(workspaceRoot, ".ling", "spec"); + const templatesDir = path.join(specDir, "templates"); + const referencesDir = path.join(specDir, "references"); + const profilesDir = path.join(specDir, "profiles"); + + const hasSpecDir = fs.existsSync(specDir); + if (!hasSpecDir) { + if (issuesResult.status !== "missing") { + issues.push("Missing .ling/spec directory (run: ling spec init)"); + } + } else { + if (issuesResult.status === "missing") { + issues.push("Missing issues.csv (run: ling spec init)"); + } + for (const rel of SPEC_TEMPLATE_REQUIRED_FILES) { + const filePath = path.join(templatesDir, rel); + if (!fs.existsSync(filePath)) { + issues.push(`Missing spec template: .ling/spec/templates/${rel}`); + } + } + for (const rel of SPEC_REFERENCE_REQUIRED_FILES) { + const filePath = path.join(referencesDir, rel); + if (!fs.existsSync(filePath)) { + issues.push(`Missing spec reference: .ling/spec/references/${rel}`); + } + } + for (const rel of SPEC_PROFILE_REQUIRED_FILES) { + const filePath = path.join(profilesDir, ...rel.split("/")); + if (!fs.existsSync(filePath)) { + issues.push(`Missing spec profile: .ling/spec/profiles/${rel}`); + } + } + } + + if (issuesResult.status === "broken") { + issues.push(...issuesResult.issues); + } + + const hasAnySpecSignal = hasSpecDir || issuesResult.status !== "missing"; + if (!hasAnySpecSignal) { + return { status: "missing", issues: [], stats: { total: 0, inProgress: 0 } }; + } + + return { + status: issues.length > 0 ? "broken" : "ok", + issues, + stats: issuesResult.stats, + }; +} + +function resolveWorkspaceSpecBackupRoot(workspaceRoot, timestamp) { + return path.join(workspaceRoot, ".ling", "backups", "spec", timestamp, "before"); +} + +async function commandSpecInit(options) { + const workspaceRoot = options.path ? resolveWorkspaceRoot(options.path) : getSpecWorkspaceDir(); + const prompter = createConflictPrompter(options); + const timestamp = nowISO().replace(/[:.]/g, "-"); + const backupRoot = resolveWorkspaceSpecBackupRoot(workspaceRoot, timestamp); + + const specDir = path.join(workspaceRoot, ".ling", "spec"); + let specSourceDir = BUNDLED_SPEC_DIR; + let cleanupSpec = null; + if (options.branch) { + const remote = cloneBranchSpecDir(options.branch, { + quiet: options.quiet, + logger: log.bind(null, options), + }); + specSourceDir = remote.specDir; + cleanupSpec = remote.cleanup; + } + const assets = { + templates: { + sourceDir: path.join(specSourceDir, "templates"), + destDir: path.join(specDir, "templates"), + }, + references: { + sourceDir: path.join(specSourceDir, "references"), + destDir: path.join(specDir, "references"), + }, + profiles: { + sourceDir: path.join(specSourceDir, "profiles"), + destDir: path.join(specDir, "profiles"), + }, + }; + + try { + fs.mkdirSync(workspaceRoot, { recursive: true }); + + for (const [assetName, config] of Object.entries(assets)) { + const exists = fs.existsSync(config.destDir); + if (exists && areDirectoriesEqual(config.sourceDir, config.destDir)) { + log(options, `[skip] Spec ${assetName} 已最新,无需覆盖: ${config.destDir}`); + continue; + } + + let action = exists ? "backup" : ""; + if (exists && prompter) { + action = await prompter.resolveConflict({ + category: "spec:project:assets", + label: `Spec ${assetName}`, + path: config.destDir, + }); + } else if (exists && !prompter && (options.force || options.nonInteractive || !process.stdin.isTTY)) { + action = "backup"; + } + + if (action === "keep") { + log(options, `[skip] 已保留 Spec ${assetName} 资产,不覆盖: ${config.destDir}`); + continue; + } + + if (exists && action !== "remove") { + backupDirSnapshot(config.destDir, path.join(backupRoot, "assets", assetName), options, `Spec ${assetName}`); + } else if (exists && action === "remove") { + removeDirIfExists(config.destDir, options, `Spec ${assetName}`); + } + applyDirSnapshot(config.sourceDir, config.destDir, options, `Spec ${assetName}`); + } + + const issuesPath = path.join(workspaceRoot, "issues.csv"); + const issuesTemplatePath = path.join(specSourceDir, "templates", "issues.template.csv"); + const issuesTemplate = stripUtf8Bom(fs.readFileSync(issuesTemplatePath, "utf8")); + const hasIssues = fs.existsSync(issuesPath); + if (hasIssues) { + let action = "backup"; + if (prompter) { + action = await prompter.resolveConflict({ + category: "spec:project:file", + label: "issues.csv", + path: issuesPath, + }); + } + if (action === "keep") { + log(options, "[skip] 已保留现有 issues.csv,不覆盖"); + } else { + if (action !== "remove") { + backupFileSnapshot(issuesPath, path.join(backupRoot, "issues.csv"), options, "issues.csv"); + } else if (!options.dryRun) { + fs.rmSync(issuesPath, { force: true }); + } + atomicWriteFile(issuesPath, `${issuesTemplate.trimEnd()}\n`, options, "issues.csv"); + log(options, options.dryRun ? `[dry-run] 将写入任务跟踪文件: ${issuesPath}` : `[ok] 已写入任务跟踪文件: ${issuesPath}`); + } + } else { + atomicWriteFile(issuesPath, `${issuesTemplate.trimEnd()}\n`, options, "issues.csv"); + log(options, options.dryRun ? `[dry-run] 将创建任务跟踪文件: ${issuesPath}` : `[ok] 已创建任务跟踪文件: ${issuesPath}`); + } + + const docsReviewsDir = path.join(workspaceRoot, "docs", "reviews"); + const docsHandoffDir = path.join(workspaceRoot, "docs", "handoff"); + if (options.dryRun) { + log(options, `[dry-run] 将确保目录存在: ${docsReviewsDir}`); + log(options, `[dry-run] 将确保目录存在: ${docsHandoffDir}`); + } else { + fs.mkdirSync(docsReviewsDir, { recursive: true }); + fs.mkdirSync(docsHandoffDir, { recursive: true }); + } + + const requestedTargets = normalizeTargets(options.targets); + const shouldInitTargets = options.path ? requestedTargets.length > 0 : true; + const targets = shouldInitTargets ? (requestedTargets.length > 0 ? requestedTargets : [...SUPPORTED_TARGETS]) : []; + if (targets.length > 0) { + await initTargets(workspaceRoot, targets, options, prompter); + } + + log(options, `[ok] Spec 初始化完成: ${workspaceRoot}`); + } finally { + if (cleanupSpec) cleanupSpec(); + if (prompter) prompter.close(); + } +} + +function commandSpecDoctor(options) { + const workspaceRoot = options.path ? resolveWorkspaceRoot(options.path) : getSpecWorkspaceDir(); + const result = checkSpecProjectIntegrity(workspaceRoot); + const state = result.status === "ok" ? "installed" : result.status; + + if (options.quiet) { + console.log(state); + setQuietStatusExitCode(state); + return; + } + + if (state === "missing") { + console.log("[warn] 未检测到 Spec 项目资产"); + console.log(` 工作区: ${workspaceRoot}`); + setQuietStatusExitCode("missing"); + return; + } + + console.log(state === "installed" ? "[ok] Spec 项目资产状态正常" : "[warn] Spec 项目资产存在问题"); + console.log(` 工作区: ${workspaceRoot}`); + console.log(` 任务数: ${result.stats.total}`); + console.log(` 进行中: ${result.stats.inProgress}`); + for (const issue of result.issues || []) { + console.log(` Issue: ${issue}`); + } + setQuietStatusExitCode(state); +} + async function commandSpecEnable(options) { ensureBundledSpecResources(); const targets = resolveTargetsForSpec(options); @@ -1844,6 +2323,7 @@ function commandSpecDisable(options) { if (remainingTargets.length === 0) { restoreSpecAsset(state.assets.templates, options, "Spec templates"); restoreSpecAsset(state.assets.references, options, "Spec references"); + restoreSpecAsset(state.assets.profiles, options, "Spec profiles"); state.assets = {}; } @@ -1851,6 +2331,14 @@ function commandSpecDisable(options) { if (!options.dryRun) { if (remainingTargets.length === 0) { removeSpecStateFile(); + const specHome = getSpecHomeDir(); + try { + if (fs.existsSync(specHome) && fs.readdirSync(specHome).length === 0) { + fs.rmdirSync(specHome); + } + } catch (err) { + log(options, `[warn] 无法清理 Spec 根目录: ${err.message}`); + } } else { writeSpecState(statePath, state); } @@ -1870,87 +2358,97 @@ async function commandSpec(options) { if (subcommand === "disable") { return commandSpecDisable(options); } + if (subcommand === "init") { + return await commandSpecInit(options); + } + if (subcommand === "doctor") { + return commandSpecDoctor(options); + } throw new Error(`未知 spec 子命令: ${subcommand}`); } -async function commandInit(options) { - const workspaceRoot = resolveWorkspaceRoot(options.path); - const targets = await resolveTargetsForInit(options); - const prompter = createConflictPrompter(options); +async function initTargets(workspaceRoot, targets, options, prompter) { + for (const target of targets) { + const runOptions = { ...options }; + const conflicts = []; - try { - for (const target of targets) { - const runOptions = { ...options }; - const conflicts = []; + if (target === "gemini") { + const agentDir = path.join(workspaceRoot, ".agent"); + if (fs.existsSync(agentDir)) { + conflicts.push({ + category: "project:gemini", + label: ".agent", + path: agentDir, + target, + }); + } + } - if (target === "gemini") { - const agentDir = path.join(workspaceRoot, ".agent"); - if (fs.existsSync(agentDir)) { + if (target === "codex") { + const managedDir = path.join(workspaceRoot, ".agents"); + const legacyDir = path.join(workspaceRoot, ".codex"); + if (fs.existsSync(managedDir) || fs.existsSync(legacyDir)) { + if (fs.existsSync(managedDir)) { conflicts.push({ - category: "project:gemini", - label: ".agent", - path: agentDir, + category: "project:codex", + label: ".agents", + path: managedDir, target, }); } - } - - if (target === "codex") { - const managedDir = path.join(workspaceRoot, ".agents"); - const legacyDir = path.join(workspaceRoot, ".codex"); - if (fs.existsSync(managedDir) || fs.existsSync(legacyDir)) { - if (fs.existsSync(managedDir)) { - conflicts.push({ - category: "project:codex", - label: ".agents", - path: managedDir, - target, - }); - } - if (fs.existsSync(legacyDir)) { - conflicts.push({ - category: "project:codex", - label: ".codex", - path: legacyDir, - target, - }); - } + if (fs.existsSync(legacyDir)) { + conflicts.push({ + category: "project:codex", + label: ".codex", + path: legacyDir, + target, + }); } } + } - if (conflicts.length > 0) { - if (!prompter && !runOptions.force) { - throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。"); - } + if (conflicts.length > 0) { + if (!prompter && !runOptions.force) { + throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。"); + } - const timestamp = nowISO().replace(/[:.]/g, "-"); - let shouldSkip = false; + const timestamp = nowISO().replace(/[:.]/g, "-"); + let shouldSkip = false; - for (const conflict of conflicts) { - const action = prompter ? await prompter.resolveConflict(conflict) : "backup"; - if (action === "keep") { - log(options, `[skip] 已保留现有资产,跳过初始化: ${conflict.label}`); - shouldSkip = true; - break; - } - if (action === "backup") { - const backupRootName = conflict.label === ".agent" ? ".agent-backup" : ".agents-backup"; - backupWorkspaceDir(workspaceRoot, conflict.path, backupRootName, timestamp, options, `工作区资产 ${conflict.label}`); - } - // remove/backup 都需要强制覆盖才能继续 - runOptions.force = true; + for (const conflict of conflicts) { + const action = prompter ? await prompter.resolveConflict(conflict) : "backup"; + if (action === "keep") { + log(options, `[skip] 已保留现有资产,跳过初始化: ${conflict.label}`); + shouldSkip = true; + break; } - - if (shouldSkip) { - continue; + if (action === "backup") { + const backupRootName = conflict.label === ".agent" ? ".agent-backup" : ".agents-backup"; + backupWorkspaceDir(workspaceRoot, conflict.path, backupRootName, timestamp, options, `工作区资产 ${conflict.label}`); } + // remove/backup 都需要强制覆盖才能继续 + runOptions.force = true; } - const adapter = createAdapter(target, workspaceRoot, runOptions); - log(options, `[sync] 正在初始化目标 [${target}] ...`); - adapter.install(BUNDLED_AGENT_DIR); - registerWorkspaceTarget(workspaceRoot, target, runOptions); + if (shouldSkip) { + continue; + } } + + const adapter = createAdapter(target, workspaceRoot, runOptions); + log(options, `[sync] 正在初始化目标 [${target}] ...`); + adapter.install(BUNDLED_AGENT_DIR); + registerWorkspaceTarget(workspaceRoot, target, runOptions); + } +} + +async function commandInit(options) { + const workspaceRoot = resolveWorkspaceRoot(options.path); + const targets = await resolveTargetsForInit(options); + const prompter = createConflictPrompter(options); + + try { + await initTargets(workspaceRoot, targets, options, prompter); } finally { if (prompter) prompter.close(); } @@ -2385,6 +2883,22 @@ async function commandDoctor(options) { } } + const specResult = checkSpecProjectIntegrity(workspaceRoot); + if (specResult.status !== "missing") { + out(`\n[SPEC] 检查 Spec 项目资产...`); + if (specResult.status === "ok") { + out(" [ok] 状态正常"); + out(` - 任务数: ${specResult.stats.total}, 进行中: ${specResult.stats.inProgress}`); + } else { + out(` [error] 状态: ${specResult.status}`); + out(` - 任务数: ${specResult.stats.total}, 进行中: ${specResult.stats.inProgress}`); + for (const issue of specResult.issues || []) { + out(` - ${issue}`); + } + hasIssue = true; + } + } + if (hasIssue) { process.exitCode = 1; } diff --git a/bin/utils.js b/bin/utils.js index bb1d02d..1b354e7 100644 --- a/bin/utils.js +++ b/bin/utils.js @@ -1,9 +1,17 @@ -const { execSync } = require("child_process"); +const { execSync, spawnSync } = require("child_process"); const fs = require("fs"); const path = require("path"); const os = require("os"); -const REPO_URL = "https://github.com/MisonL/Ling.git"; +const DEFAULT_REPO_URL = "https://github.com/MisonL/Ling.git"; + +function resolveRepoUrl() { + const override = process.env.LING_REPO_URL; + if (typeof override === "string" && override.trim()) { + return override.trim(); + } + return DEFAULT_REPO_URL; +} function parseJsonSafe(raw) { try { @@ -51,14 +59,15 @@ function cloneBranchAgentDir(branch, options) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ling-")); const logFn = options && options.logger ? options.logger : console.log; + const repoUrl = resolveRepoUrl(); - if (!options.quiet) logFn(`[download] 正在从 ${REPO_URL} 拉取分支 ${safeBranch} ...`); + if (!options.quiet) logFn(`[download] 正在从 ${repoUrl} 拉取分支 ${safeBranch} ...`); - try { - execSync(`git clone --depth 1 --branch ${safeBranch} ${REPO_URL} "${tempDir}"`, { - stdio: options.quiet ? "ignore" : "pipe", - }); - } catch (err) { + const cloneResult = spawnSync("git", ["clone", "--depth", "1", "--branch", safeBranch, repoUrl, tempDir], { + encoding: "utf8", + stdio: options.quiet ? "ignore" : "pipe", + }); + if (cloneResult.status !== 0) { fs.rmSync(tempDir, { recursive: true, force: true }); throw new Error(`无法拉取分支 ${safeBranch},请确认分支存在且网络可用`); } @@ -82,8 +91,42 @@ function cloneBranchAgentDir(branch, options) { }; } +function cloneBranchSpecDir(branch, options) { + const safeBranch = branch.trim(); + if (!/^[A-Za-z0-9._/-]+$/.test(safeBranch)) { + throw new Error(`非法分支名: ${branch}`); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ling-")); + const logFn = options && options.logger ? options.logger : console.log; + const repoUrl = resolveRepoUrl(); + + if (!options.quiet) logFn(`[download] 正在从 ${repoUrl} 拉取分支 ${safeBranch} (spec) ...`); + + const cloneResult = spawnSync("git", ["clone", "--depth", "1", "--branch", safeBranch, repoUrl, tempDir], { + encoding: "utf8", + stdio: options.quiet ? "ignore" : "pipe", + }); + if (cloneResult.status !== 0) { + fs.rmSync(tempDir, { recursive: true, force: true }); + throw new Error(`无法拉取分支 ${safeBranch},请确认分支存在且网络可用`); + } + + const specDir = path.join(tempDir, ".spec"); + if (!fs.existsSync(specDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + throw new Error(`分支 ${safeBranch} 中未找到 .spec 目录`); + } + + return { + specDir, + cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }), + }; +} + module.exports = { parseJsonSafe, readGlobalNpmDependencies, - cloneBranchAgentDir + cloneBranchAgentDir, + cloneBranchSpecDir }; diff --git a/docs/TECH.md b/docs/TECH.md index 8ca4f12..719f24f 100644 --- a/docs/TECH.md +++ b/docs/TECH.md @@ -65,19 +65,27 @@ cd web && npm install && npm run lint ### 测试隔离 - `LING_GLOBAL_ROOT`:替代 `$HOME`(用于测试与 CI,避免污染真实用户目录) -## Spec Profile:`ling spec enable/disable/status` +## Spec Profile:`ling spec ...` ### 当前范围 -- 当前只实现全局层: +- 全局 Spec Profile(机器级): - `ling spec enable [--target codex|gemini] [--dry-run] [--quiet]` - `ling spec disable [--target codex|gemini] [--dry-run] [--quiet]` - `ling spec status [--quiet]` -- 默认目标:未指定 `--target/--targets` 时启用 `codex + gemini` -- 当前 Spec 源目录:`.spec/` + - 默认目标:未指定 `--target/--targets` 时启用 `codex + gemini` +- 项目级 Spec 资产(工作区级): + - `ling spec init [--path ] [--target codex|gemini|--targets codex,gemini] [--branch ] [--dry-run] [--quiet]` + - 未指定 `--path` 时,默认初始化 Spec 工作区目录:`$HOME/.ling/spec-workspace` + - 指定 `--targets` 时,会同时执行对应目标的 `ling init` 安装(`.agent/.agents`) + - 指定 `--branch` 时,Spec 资产将从该分支的 `.spec/` 拉取(用于验证或灰度 Spec 模板) + - `ling spec doctor [--path ] [--quiet]` + - 会校验 `issues.csv` 表头与状态枚举,并检查 `.ling/spec/` 目录是否完整 +- Spec 源目录:`.spec/` ### 落盘与状态 - Spec 状态文件:`$HOME/.ling/spec/state.json` - Spec templates:`$HOME/.ling/spec/templates/` - Spec references:`$HOME/.ling/spec/references/` +- Spec profiles:`$HOME/.ling/spec/profiles/` - Spec 备份目录:`$HOME/.ling/backups/spec//before/...` ### 当前安装内容 @@ -94,6 +102,10 @@ cd web && npm install && npm run lint - `harness-engineering-digest.md` - `gda-framework.md` - 相关 quickstart / README +- Profiles: + - `codex/AGENTS.spec.md` + - `codex/ling.spec.rules.md` + - `gemini/GEMINI.spec.md` ### 状态契约 - `ling spec status --quiet` 输出: @@ -108,11 +120,13 @@ cd web && npm install && npm run lint ### 回退语义 - `spec enable`: - 若目标位置已存在同名 Skill,会先备份再覆盖 - - 若 `templates/` 或 `references/` 已存在,也会先备份 + - 若 `templates/`、`references/` 或 `profiles/` 已存在,也会先备份 - `spec disable`: - 若存在备份,恢复启用前快照 - 若启用前不存在资源,则删除由 Spec 安装的目录 -- 当前尚未实现项目级 `spec init / remove / doctor` +- `spec init`: + - 会在工作区内写入 `.ling/spec/`(templates/references/profiles)与 `issues.csv`,并创建 `docs/reviews`、`docs/handoff` + - 当检测到冲突资产时,支持保留 / 备份后覆盖 / 直接覆盖 ## 状态契约(自动化) - `ling status --quiet` / `ling global status --quiet` 只输出三态: @@ -168,6 +182,7 @@ cp -a "$HOME/.ling/backups/global/$ts/antigravity/$skill" "$HOME/.gemini/antigra - `LING_INDEX_PATH`:工作区索引文件路径(默认 `~/.ling/workspaces.json`) - `LING_GLOBAL_ROOT`:全局目录根(替代 `$HOME`) - `LING_SKIP_UPSTREAM_CHECK`:跳过上游同名包安装提示(测试用) +- `LING_REPO_URL`:覆盖默认仓库地址(主要用于测试与私有镜像) ## 安装提示机制 - npm 全局安装:`postinstall` 会尽力检测并提示上游英文版 `@vudovn/ag-kit` 冲突。 diff --git a/package-lock.json b/package-lock.json index f938287..e183a37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mison/ling", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mison/ling", - "version": "1.1.0", + "version": "1.1.1", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 5932a46..7c09499 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mison/ling", - "version": "1.1.0", + "version": "1.1.1", "description": "面向 Gemini CLI、Antigravity 与 Codex 的中文 AI Agent 模板工具包", "repository": { "type": "git", diff --git a/tests/spec-init-doctor.test.js b/tests/spec-init-doctor.test.js new file mode 100644 index 0000000..f0ed645 --- /dev/null +++ b/tests/spec-init-doctor.test.js @@ -0,0 +1,175 @@ +const { test, describe, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("node:child_process"); + +const REPO_ROOT = path.resolve(__dirname, ".."); +const CLI_PATH = path.join(REPO_ROOT, "bin", "ling.js"); + +function runCli(args, options = {}) { + const env = { + ...process.env, + LING_SKIP_UPSTREAM_CHECK: "1", + ...options.env, + }; + + return spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: options.cwd || REPO_ROOT, + env, + encoding: "utf8", + }); +} + +describe("Spec init/doctor", () => { + let tempRoot; + let workspaceRoot; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ling-spec-init-")); + workspaceRoot = path.join(tempRoot, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + test("spec init should create workspace assets and doctor should report installed", () => { + const env = { + LING_GLOBAL_ROOT: tempRoot, + LING_INDEX_PATH: path.join(tempRoot, "workspaces.json"), + }; + + const initResult = runCli(["spec", "init", "--path", workspaceRoot, "--quiet"], { env }); + assert.strictEqual(initResult.status, 0, initResult.stderr || initResult.stdout); + + assert.ok(fs.existsSync(path.join(workspaceRoot, "issues.csv")), "issues.csv should be created"); + assert.ok(fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "templates", "driver-prompt.md")), "spec templates should be created"); + assert.ok(fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "references", "gda-framework.md")), "spec references should be created"); + assert.ok(fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "profiles", "codex", "AGENTS.spec.md")), "spec profiles should be created"); + assert.ok( + fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "profiles", "codex", "ling.spec.rules.md")), + "spec profile rules should be created", + ); + assert.ok(fs.existsSync(path.join(workspaceRoot, ".ling", "spec", "profiles", "gemini", "GEMINI.spec.md")), "spec profiles should be created"); + assert.ok(fs.existsSync(path.join(workspaceRoot, "docs", "reviews")), "docs/reviews should exist"); + assert.ok(fs.existsSync(path.join(workspaceRoot, "docs", "handoff")), "docs/handoff should exist"); + + const doctorResult = runCli(["spec", "doctor", "--path", workspaceRoot, "--quiet"], { env }); + assert.strictEqual(doctorResult.status, 0, doctorResult.stderr || doctorResult.stdout); + assert.strictEqual((doctorResult.stdout || "").trim(), "installed"); + }); + + test("spec doctor should report broken when multiple tasks are in 进行中", () => { + const env = { + LING_GLOBAL_ROOT: tempRoot, + LING_INDEX_PATH: path.join(tempRoot, "workspaces.json"), + }; + + const initResult = runCli(["spec", "init", "--path", workspaceRoot, "--quiet"], { env }); + assert.strictEqual(initResult.status, 0, initResult.stderr || initResult.stdout); + + const issuesPath = path.join(workspaceRoot, "issues.csv"); + fs.writeFileSync( + issuesPath, + [ + "ID,标题,内容,验收标准,审查要求,状态,标签", + "A,任务A,内容A,验收A,审查A,进行中,高优先级", + "B,任务B,内容B,验收B,审查B,进行中,高优先级", + "", + ].join("\n"), + "utf8", + ); + + const doctorResult = runCli(["spec", "doctor", "--path", workspaceRoot, "--quiet"], { env }); + assert.strictEqual(doctorResult.status, 1, doctorResult.stderr || doctorResult.stdout); + assert.strictEqual((doctorResult.stdout || "").trim(), "broken"); + }); + + test("spec doctor should report broken when issues.csv is missing but spec directory exists", () => { + const env = { + LING_GLOBAL_ROOT: tempRoot, + LING_INDEX_PATH: path.join(tempRoot, "workspaces.json"), + }; + + const initResult = runCli(["spec", "init", "--path", workspaceRoot, "--quiet"], { env }); + assert.strictEqual(initResult.status, 0, initResult.stderr || initResult.stdout); + + fs.rmSync(path.join(workspaceRoot, "issues.csv"), { force: true }); + + const doctorResult = runCli(["spec", "doctor", "--path", workspaceRoot, "--quiet"], { env }); + assert.strictEqual(doctorResult.status, 1, doctorResult.stderr || doctorResult.stdout); + assert.strictEqual((doctorResult.stdout || "").trim(), "broken"); + }); + + test("spec init should support --branch for spec assets", () => { + const gitCheck = spawnSync("git", ["--version"], { encoding: "utf8" }); + if (gitCheck.status !== 0) { + return; + } + + const sourceRepo = path.join(tempRoot, "spec-source-repo"); + fs.mkdirSync(sourceRepo, { recursive: true }); + + const runGit = (args) => + spawnSync("git", args, { + cwd: sourceRepo, + encoding: "utf8", + }); + + const initRes = runGit(["init", "--initial-branch", "main"]); + if (initRes.status !== 0) { + assert.strictEqual(runGit(["init"]).status, 0); + assert.strictEqual(runGit(["checkout", "-b", "main"]).status, 0); + } + + const specRoot = path.join(sourceRepo, ".spec"); + const templatesDir = path.join(specRoot, "templates"); + const referencesDir = path.join(specRoot, "references"); + const profilesCodexDir = path.join(specRoot, "profiles", "codex"); + const profilesGeminiDir = path.join(specRoot, "profiles", "gemini"); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.mkdirSync(referencesDir, { recursive: true }); + fs.mkdirSync(profilesCodexDir, { recursive: true }); + fs.mkdirSync(profilesGeminiDir, { recursive: true }); + + fs.writeFileSync(path.join(templatesDir, "issues.template.csv"), "ID,状态\nX,未开始\n", "utf8"); + fs.writeFileSync(path.join(templatesDir, "driver-prompt.md"), "branch driver prompt", "utf8"); + fs.writeFileSync(path.join(templatesDir, "review-report.md"), "branch review report", "utf8"); + fs.writeFileSync(path.join(templatesDir, "phase-acceptance.md"), "branch acceptance", "utf8"); + fs.writeFileSync(path.join(templatesDir, "handoff.md"), "branch handoff", "utf8"); + + fs.writeFileSync(path.join(referencesDir, "README.md"), "branch readme", "utf8"); + fs.writeFileSync(path.join(referencesDir, "harness-engineering-digest.md"), "branch digest", "utf8"); + fs.writeFileSync(path.join(referencesDir, "gda-framework.md"), "branch gda", "utf8"); + fs.writeFileSync(path.join(referencesDir, "cse-quickstart.md"), "branch quickstart", "utf8"); + + fs.writeFileSync(path.join(profilesCodexDir, "AGENTS.spec.md"), "branch codex agents", "utf8"); + fs.writeFileSync(path.join(profilesCodexDir, "ling.spec.rules.md"), "branch codex rules", "utf8"); + fs.writeFileSync(path.join(profilesGeminiDir, "GEMINI.spec.md"), "branch gemini profile", "utf8"); + + assert.strictEqual(runGit(["add", "."]).status, 0); + assert.strictEqual( + runGit(["-c", "user.name=ling-test", "-c", "user.email=ling-test@example.com", "commit", "-m", "spec assets"]).status, + 0, + ); + + const env = { + LING_GLOBAL_ROOT: tempRoot, + LING_REPO_URL: sourceRepo, + LING_INDEX_PATH: path.join(tempRoot, "workspaces.json"), + }; + + const initResult = runCli(["spec", "init", "--path", workspaceRoot, "--branch", "main", "--quiet"], { env }); + assert.strictEqual(initResult.status, 0, initResult.stderr || initResult.stdout); + + const installedPrompt = path.join(workspaceRoot, ".ling", "spec", "templates", "driver-prompt.md"); + assert.strictEqual(fs.readFileSync(installedPrompt, "utf8"), "branch driver prompt"); + + const doctorResult = runCli(["spec", "doctor", "--path", workspaceRoot, "--quiet"], { env }); + assert.strictEqual(doctorResult.status, 0, doctorResult.stderr || doctorResult.stdout); + assert.strictEqual((doctorResult.stdout || "").trim(), "installed"); + }); +}); diff --git a/tests/spec-profile.test.js b/tests/spec-profile.test.js index 7cc09be..eab952f 100644 --- a/tests/spec-profile.test.js +++ b/tests/spec-profile.test.js @@ -51,11 +51,14 @@ describe("Spec Profile", () => { const stateFile = path.join(tempRoot, ".ling", "spec", "state.json"); const templatesDir = path.join(tempRoot, ".ling", "spec", "templates"); const referencesDir = path.join(tempRoot, ".ling", "spec", "references"); + const profilesDir = path.join(tempRoot, ".ling", "spec", "profiles"); assert.ok(fs.existsSync(codexSkill), "missing installed codex spec skill"); assert.ok(fs.existsSync(stateFile), "missing spec state"); assert.ok(fs.existsSync(path.join(templatesDir, "issues.template.csv")), "missing spec template"); assert.ok(fs.existsSync(path.join(referencesDir, "harness-engineering-digest.md")), "missing spec reference"); + assert.ok(fs.existsSync(path.join(profilesDir, "codex", "AGENTS.spec.md")), "missing spec profile"); + assert.ok(fs.existsSync(path.join(profilesDir, "codex", "ling.spec.rules.md")), "missing spec profile rules"); const statusResult = runCli(["spec", "status", "--quiet"], { env }); assert.strictEqual(statusResult.status, 0); @@ -67,6 +70,7 @@ describe("Spec Profile", () => { assert.ok(!fs.existsSync(stateFile), "spec state should be removed after final disable"); assert.ok(!fs.existsSync(templatesDir), "spec templates should be removed after final disable"); assert.ok(!fs.existsSync(referencesDir), "spec references should be removed after final disable"); + assert.ok(!fs.existsSync(profilesDir), "spec profiles should be removed after final disable"); }); test("spec disable should restore pre-existing skill backup", () => { @@ -83,4 +87,68 @@ describe("Spec Profile", () => { assert.strictEqual(disableResult.status, 0, disableResult.stderr || disableResult.stdout); assert.strictEqual(fs.readFileSync(path.join(skillDir, "SKILL.md"), "utf8"), "legacy skill"); }); + + test("spec enable should repair missing assets and skills when state exists", () => { + const env = { LING_GLOBAL_ROOT: tempRoot }; + + const enableResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env }); + assert.strictEqual(enableResult.status, 0, enableResult.stderr || enableResult.stdout); + + const templatesDir = path.join(tempRoot, ".ling", "spec", "templates"); + const codexSkillDir = path.join(tempRoot, ".codex", "skills", "harness-engineering"); + fs.rmSync(templatesDir, { recursive: true, force: true }); + fs.rmSync(codexSkillDir, { recursive: true, force: true }); + + const brokenResult = runCli(["spec", "status", "--quiet"], { env }); + assert.strictEqual(brokenResult.status, 1); + assert.strictEqual((brokenResult.stdout || "").trim(), "broken"); + + const repairResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env }); + assert.strictEqual(repairResult.status, 0, repairResult.stderr || repairResult.stdout); + + const repairedStatus = runCli(["spec", "status", "--quiet"], { env }); + assert.strictEqual(repairedStatus.status, 0); + assert.strictEqual((repairedStatus.stdout || "").trim(), "installed"); + + assert.ok(fs.existsSync(path.join(templatesDir, "issues.template.csv")), "templates should be repaired"); + assert.ok(fs.existsSync(path.join(codexSkillDir, "SKILL.md")), "spec skill should be repaired"); + }); + + test("spec status should report broken when an asset file is missing", () => { + const env = { LING_GLOBAL_ROOT: tempRoot }; + + const enableResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env }); + assert.strictEqual(enableResult.status, 0, enableResult.stderr || enableResult.stdout); + + const driverPrompt = path.join(tempRoot, ".ling", "spec", "templates", "driver-prompt.md"); + assert.ok(fs.existsSync(driverPrompt), "driver-prompt.md should exist after enable"); + fs.rmSync(driverPrompt, { force: true }); + + const statusResult = runCli(["spec", "status", "--quiet"], { env }); + assert.strictEqual(statusResult.status, 1); + assert.strictEqual((statusResult.stdout || "").trim(), "broken"); + + const repairResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env }); + assert.strictEqual(repairResult.status, 0, repairResult.stderr || repairResult.stdout); + assert.ok(fs.existsSync(driverPrompt), "driver-prompt.md should be repaired"); + }); + + test("spec status should report broken when state.json is missing but assets exist", () => { + const env = { LING_GLOBAL_ROOT: tempRoot }; + + const templatesDir = path.join(tempRoot, ".ling", "spec", "templates"); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.writeFileSync(path.join(templatesDir, "issues.template.csv"), "sentinel", "utf8"); + + const statusResult = runCli(["spec", "status", "--quiet"], { env }); + assert.strictEqual(statusResult.status, 1); + assert.strictEqual((statusResult.stdout || "").trim(), "broken"); + + const repairResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env }); + assert.strictEqual(repairResult.status, 0, repairResult.stderr || repairResult.stdout); + + const repairedStatus = runCli(["spec", "status", "--quiet"], { env }); + assert.strictEqual(repairedStatus.status, 0); + assert.strictEqual((repairedStatus.stdout || "").trim(), "installed"); + }); });