From c4d1fbc459216e4eb4a0575b8cf257d0e14b7c7f Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:39:11 +0800 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20=E9=87=8D=E7=BB=84=20src/core?= =?UTF-8?q?=20=E5=B1=82=E7=BA=A7=EF=BC=8C=E6=8B=86=E5=88=86=E4=B8=BA=20mod?= =?UTF-8?q?el/project/change/artifact/workflow=20+=20util=20+=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/{ => artifact}/design.ts | 4 +- src/core/{ => artifact}/instructions.ts | 14 +- src/core/{ => artifact}/proposal.ts | 6 +- src/core/{ => artifact}/specs.ts | 4 +- src/core/{ => artifact}/tasks.ts | 4 +- src/core/{ => change}/change.ts | 8 +- src/core/{ => change}/list.ts | 6 +- src/core/{ => change}/new.ts | 6 +- src/core/{ => change}/status.ts | 10 +- src/core/index.ts | 44 +++---- src/core/{ => model}/schema.ts | 0 src/core/{ => model}/schemas.ts | 2 +- src/core/{ => model}/types.ts | 0 src/core/paths.ts | 167 +----------------------- src/core/{ => project}/config.ts | 6 +- src/core/{ => project}/init.ts | 4 +- src/core/{ => workflow}/apply.ts | 4 +- src/core/{ => workflow}/continue.ts | 18 +-- src/core/{ => workflow}/ff.ts | 6 +- src/core/{ => workflow}/propose.ts | 10 +- src/core/{ => workflow}/validate.ts | 6 +- src/core/{ => workflow}/verify.ts | 4 +- src/{core => sync}/archive.ts | 6 +- src/{core => sync}/bulk-archive.ts | 0 src/{core => sync}/sync.ts | 8 +- src/{core => util}/common.ts | 0 src/{core => util}/fs.ts | 0 src/util/paths.ts | 166 +++++++++++++++++++++++ src/{core => util}/tasks-format.ts | 0 src/{core => util}/templates.ts | 0 30 files changed, 257 insertions(+), 256 deletions(-) rename src/core/{ => artifact}/design.ts (90%) rename src/core/{ => artifact}/instructions.ts (86%) rename src/core/{ => artifact}/proposal.ts (90%) rename src/core/{ => artifact}/specs.ts (88%) rename src/core/{ => artifact}/tasks.ts (89%) rename src/core/{ => change}/change.ts (96%) rename src/core/{ => change}/list.ts (93%) rename src/core/{ => change}/new.ts (88%) rename src/core/{ => change}/status.ts (94%) rename src/core/{ => model}/schema.ts (100%) rename src/core/{ => model}/schemas.ts (92%) rename src/core/{ => model}/types.ts (100%) rename src/core/{ => project}/config.ts (87%) rename src/core/{ => project}/init.ts (87%) rename src/core/{ => workflow}/apply.ts (95%) rename src/core/{ => workflow}/continue.ts (76%) rename src/core/{ => workflow}/ff.ts (89%) rename src/core/{ => workflow}/propose.ts (89%) rename src/core/{ => workflow}/validate.ts (94%) rename src/core/{ => workflow}/verify.ts (93%) rename src/{core => sync}/archive.ts (90%) rename src/{core => sync}/bulk-archive.ts (100%) rename src/{core => sync}/sync.ts (86%) rename src/{core => util}/common.ts (100%) rename src/{core => util}/fs.ts (100%) create mode 100644 src/util/paths.ts rename src/{core => util}/tasks-format.ts (100%) rename src/{core => util}/templates.ts (100%) diff --git a/src/core/design.ts b/src/core/artifact/design.ts similarity index 90% rename from src/core/design.ts rename to src/core/artifact/design.ts index 1dde97c..f2117e6 100644 --- a/src/core/design.ts +++ b/src/core/artifact/design.ts @@ -1,7 +1,7 @@ import path from "node:path" -import { changeDir, getTemplate, pathExists, renderTemplate, slugify, writeText } from "./common.js" -import { toRelativePath } from "./paths.js" +import { changeDir, getTemplate, pathExists, renderTemplate, slugify, writeText } from "../../util/common.js" +import { toRelativePath } from "../../util/paths.js" export interface UpdateDesignInput { projectDir: string diff --git a/src/core/instructions.ts b/src/core/artifact/instructions.ts similarity index 86% rename from src/core/instructions.ts rename to src/core/artifact/instructions.ts index f43e242..279d5e3 100644 --- a/src/core/instructions.ts +++ b/src/core/artifact/instructions.ts @@ -1,12 +1,12 @@ import path from "node:path" -import { readOptionalText } from "./fs.js" -import { getTemplate, renderTemplate } from "./templates.js" -import { getArtifactDefinition } from "./schema.js" -import { resolveChangeMeta } from "./change.js" -import { detectArtifactPaths, getArtifactStatus } from "./status.js" -import type { ArtifactId } from "./types.js" -import { changeDir, toRelativePath } from "./paths.js" +import { readOptionalText } from "../../util/fs.js" +import { getTemplate, renderTemplate } from "../../util/templates.js" +import { getArtifactDefinition } from "../model/schema.js" +import { resolveChangeMeta } from "../change/change.js" +import { detectArtifactPaths, getArtifactStatus } from "../change/status.js" +import type { ArtifactId } from "../model/types.js" +import { changeDir, toRelativePath } from "../../util/paths.js" export interface GetArtifactInstructionsInput { projectDir: string diff --git a/src/core/proposal.ts b/src/core/artifact/proposal.ts similarity index 90% rename from src/core/proposal.ts rename to src/core/artifact/proposal.ts index ace027c..cfa645f 100644 --- a/src/core/proposal.ts +++ b/src/core/artifact/proposal.ts @@ -1,6 +1,6 @@ -import { formatProposalWithFrontmatter, readProposalFrontmatter } from "./change.js" -import { changeDir, getTemplate, pathExists, readOptionalText, renderTemplate, slugify, writeText } from "./common.js" -import { proposalPath, toRelativePath } from "./paths.js" +import { formatProposalWithFrontmatter, readProposalFrontmatter } from "../change/change.js" +import { changeDir, getTemplate, pathExists, readOptionalText, renderTemplate, slugify, writeText } from "../../util/common.js" +import { proposalPath, toRelativePath } from "../../util/paths.js" export interface UpdateProposalInput { projectDir: string diff --git a/src/core/specs.ts b/src/core/artifact/specs.ts similarity index 88% rename from src/core/specs.ts rename to src/core/artifact/specs.ts index 4b8a87f..2bf5295 100644 --- a/src/core/specs.ts +++ b/src/core/artifact/specs.ts @@ -1,7 +1,7 @@ import path from "node:path" -import { changeDir, getTemplate, pathExists, renderTemplate, slugify, writeText } from "./common.js" -import { changeSpecsDir, toRelativePath } from "./paths.js" +import { changeDir, getTemplate, pathExists, renderTemplate, slugify, writeText } from "../../util/common.js" +import { changeSpecsDir, toRelativePath } from "../../util/paths.js" export interface UpdateSpecsInput { projectDir: string diff --git a/src/core/tasks.ts b/src/core/artifact/tasks.ts similarity index 89% rename from src/core/tasks.ts rename to src/core/artifact/tasks.ts index 3ffd52f..800216d 100644 --- a/src/core/tasks.ts +++ b/src/core/artifact/tasks.ts @@ -1,7 +1,7 @@ import path from "node:path" -import { changeDir, getTemplate, pathExists, renderTemplate, slugify, validateTasksMarkdown, writeText } from "./common.js" -import { toRelativePath } from "./paths.js" +import { changeDir, getTemplate, pathExists, renderTemplate, slugify, validateTasksMarkdown, writeText } from "../../util/common.js" +import { toRelativePath } from "../../util/paths.js" export interface UpdateTasksInput { projectDir: string diff --git a/src/core/change.ts b/src/core/change/change.ts similarity index 96% rename from src/core/change.ts rename to src/core/change/change.ts index 5fe2a10..95b75d0 100644 --- a/src/core/change.ts +++ b/src/core/change/change.ts @@ -2,15 +2,15 @@ import { readdir, stat } from "node:fs/promises" import path from "node:path" import { parse } from "yaml" -import { loadProjectConfig } from "./config.js" -import { pathExists, readOptionalText } from "./fs.js" +import { loadProjectConfig } from "../project/config.js" +import { pathExists, readOptionalText } from "../../util/fs.js" import { archiveRoot, archiveChangeDir, changeDir, slugify, -} from "./paths.js" -import type { ChangeMeta } from "./types.js" +} from "../../util/paths.js" +import type { ChangeMeta } from "../model/types.js" /** 变更在磁盘上的位置信息 */ interface ChangeLocation { diff --git a/src/core/list.ts b/src/core/change/list.ts similarity index 93% rename from src/core/list.ts rename to src/core/change/list.ts index 22f9cd1..0ef1705 100644 --- a/src/core/list.ts +++ b/src/core/change/list.ts @@ -1,9 +1,9 @@ import path from "node:path" import { resolveChangeMeta } from "./change.js" -import { archiveRoot, changeDir, changesRoot, listDirectories, parseTasks, readOptionalText, slugify } from "./common.js" -import { toRelativePath } from "./paths.js" -import { verifyChange } from "./verify.js" +import { archiveRoot, changeDir, changesRoot, listDirectories, parseTasks, readOptionalText, slugify } from "../../util/common.js" +import { toRelativePath } from "../../util/paths.js" +import { verifyChange } from "../workflow/verify.js" /** 变更概要信息 */ export interface ChangeSummary { diff --git a/src/core/new.ts b/src/core/change/new.ts similarity index 88% rename from src/core/new.ts rename to src/core/change/new.ts index 2abae6e..a045743 100644 --- a/src/core/new.ts +++ b/src/core/change/new.ts @@ -1,9 +1,9 @@ import { mkdir } from "node:fs/promises" import { formatProposalWithFrontmatter } from "./change.js" -import { loadProjectConfig } from "./config.js" -import { pathExists, writeText } from "./fs.js" -import { changeDir, changeSpecsDir, ensureOpenSpecStructure, proposalPath, toRelativePath, validateSlug } from "./paths.js" +import { loadProjectConfig } from "../project/config.js" +import { pathExists, writeText } from "../../util/fs.js" +import { changeDir, changeSpecsDir, ensureOpenSpecStructure, proposalPath, toRelativePath, validateSlug } from "../../util/paths.js" export interface CreateChangeScaffoldInput { projectDir: string diff --git a/src/core/status.ts b/src/core/change/status.ts similarity index 94% rename from src/core/status.ts rename to src/core/change/status.ts index bfb46b4..61d1bc5 100644 --- a/src/core/status.ts +++ b/src/core/change/status.ts @@ -1,11 +1,11 @@ import path from "node:path" import { resolveChangeLocation, resolveChangeMeta } from "./change.js" -import { listFilesRecursive, pathExists } from "./fs.js" -import { getArtifactDefinition, getSchema } from "./schema.js" -import { toRelativePath } from "./paths.js" -import type { ChangeMeta } from "./types.js" -import type { ArtifactId, ArtifactStatus, ChangeStatus } from "./types.js" +import { listFilesRecursive, pathExists } from "../../util/fs.js" +import { getArtifactDefinition, getSchema } from "../model/schema.js" +import { toRelativePath } from "../../util/paths.js" +import type { ChangeMeta } from "../model/types.js" +import type { ArtifactId, ArtifactStatus, ChangeStatus } from "../model/types.js" /** 解析变更的基准目录路径 */ async function resolveChangeBaseDir(projectDir: string, slug: string) { diff --git a/src/core/index.ts b/src/core/index.ts index efa7d64..1d3963c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,24 +1,24 @@ /** OpenSpec 核心模块统一导出入口 */ -export { archiveChange } from "./archive.js" -export { prepareApply } from "./apply.js" -export { bulkArchiveChanges } from "./bulk-archive.js" -export { loadProjectConfig } from "./config.js" -export { resolveChangeMeta } from "./change.js" -export { continueChange } from "./continue.js" -export { updateDesign } from "./design.js" -export { fastForwardChange } from "./ff.js" -export { getArtifactStatus, getChangeStatus } from "./status.js" -export { getArtifactInstructions } from "./instructions.js" -export { initializeOpenSpec } from "./init.js" -export { listChanges } from "./list.js" -export { createChangeScaffold } from "./new.js" -export { updateProposal } from "./proposal.js" -export { proposeChange } from "./propose.js" -export { listSchemas } from "./schemas.js" -export { updateSpecs } from "./specs.js" -export { listTemplateInfos, resolveTemplateInfo } from "./templates.js" -export { updateTasks } from "./tasks.js" -export { syncChangeSpecs } from "./sync.js" -export { validateChange } from "./validate.js" -export { verifyChange } from "./verify.js" +export { archiveChange } from "../sync/archive.js" +export { prepareApply } from "./workflow/apply.js" +export { bulkArchiveChanges } from "../sync/bulk-archive.js" +export { loadProjectConfig } from "./project/config.js" +export { resolveChangeMeta } from "./change/change.js" +export { continueChange } from "./workflow/continue.js" +export { updateDesign } from "./artifact/design.js" +export { fastForwardChange } from "./workflow/ff.js" +export { getArtifactStatus, getChangeStatus } from "./change/status.js" +export { getArtifactInstructions } from "./artifact/instructions.js" +export { initializeOpenSpec } from "./project/init.js" +export { listChanges } from "./change/list.js" +export { createChangeScaffold } from "./change/new.js" +export { updateProposal } from "./artifact/proposal.js" +export { proposeChange } from "./workflow/propose.js" +export { listSchemas } from "./model/schemas.js" +export { updateSpecs } from "./artifact/specs.js" +export { listTemplateInfos, resolveTemplateInfo } from "../util/templates.js" +export { updateTasks } from "./artifact/tasks.js" +export { syncChangeSpecs } from "../sync/sync.js" +export { validateChange } from "./workflow/validate.js" +export { verifyChange } from "./workflow/verify.js" diff --git a/src/core/schema.ts b/src/core/model/schema.ts similarity index 100% rename from src/core/schema.ts rename to src/core/model/schema.ts diff --git a/src/core/schemas.ts b/src/core/model/schemas.ts similarity index 92% rename from src/core/schemas.ts rename to src/core/model/schemas.ts index 87be470..ff480ed 100644 --- a/src/core/schemas.ts +++ b/src/core/model/schemas.ts @@ -1,4 +1,4 @@ -import { loadProjectConfig } from "./config.js" +import { loadProjectConfig } from "../project/config.js" import { getSchema } from "./schema.js" /** diff --git a/src/core/types.ts b/src/core/model/types.ts similarity index 100% rename from src/core/types.ts rename to src/core/model/types.ts diff --git a/src/core/paths.ts b/src/core/paths.ts index 826f768..0fd5dfd 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -1,166 +1 @@ -import { mkdir } from "node:fs/promises" -import path from "node:path" - -import { pathExists } from "./fs.js" - -/** OpenSpec 输出目录名,默认为 "openspec",可通过插件 options 自定义 */ -let _openspecDir = "openspec" - -/** 设置 OpenSpec 输出目录名 */ -export function setOpenspecDir(dir: string) { - const trimmed = dir.trim() - if (!trimmed) throw new Error("OpenSpec 目录名不能为空") - _openspecDir = trimmed -} - -/** 获取当前 OpenSpec 输出目录名 */ -export function getOpenspecDir() { - return _openspecDir -} - -/** OpenSpec 插件的唯一标识 */ -export const PLUGIN_ID = "opencode-spec" - -const MAX_SLUG_LENGTH = 60 - -/** - * 将字符串转换为 URL 友好的 slug - * - * 规则:小写、去除非字母数字字符、连字符化、去首尾连字符、合并连续连字符。 - * 长度截断到 60 字符。 - */ -export function slugify(value: string) { - let slug = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .replace(/--+/g, "-") - - if (slug.length > MAX_SLUG_LENGTH) { - const sliced = slug.slice(0, MAX_SLUG_LENGTH) - const lastDash = sliced.lastIndexOf("-") - slug = lastDash > MAX_SLUG_LENGTH / 2 ? sliced.slice(0, lastDash) : sliced - } - - return slug || "change" -} - -/** - * 验证并生成变更 slug - * - * 严格校验输入必须是合理的英文 kebab-case 短语: - * - 禁止非 ASCII 字符(中文、日文、韩文等),必须翻译为英文 - * - slug 中至少含一个连字符(至少两个单词) - * - slug 中字母数字字符至少 4 个 - */ -export function validateSlug(value: string) { - const hasNonAscii = /[^\x00-\x7F]/.test(value) - if (hasNonAscii) { - throw new Error("变更名必须为英文 kebab-case 短语,请将中文翻译为英文后再试") - } - - const slug = slugify(value) - const alphaChars = slug.replace(/[^a-z0-9]/g, "") - - if (alphaChars.length < 4) { - throw new Error(`变更名过于简洁("${slug}"),请使用更具体的英文短语,例如 "add-user-auth" 而非 "add-auth"`) - } - - if (!slug.includes("-")) { - throw new Error(`变更名至少需要 2 个单词("${slug}"),请使用更具体的英文短语`) - } - - return slug -} - -/** 将绝对路径转换为相对于项目目录的路径(使用正斜杠) */ -export function toRelativePath(projectDir: string, targetPath: string) { - return path.relative(projectDir, targetPath).replace(/\\/g, "/") -} - -/** OpenSpec 根目录:项目根下的 openspec/(目录名可通过 setOpenspecDir 自定义) */ -export function openspecRoot(projectDir: string) { - return path.join(projectDir, _openspecDir) -} - -/** 变更目录:openspec/changes/ */ -export function changesRoot(projectDir: string) { - return path.join(openspecRoot(projectDir), "changes") -} - -/** 归档变更目录:openspec/changes/archive/ */ -export function archiveRoot(projectDir: string) { - return path.join(changesRoot(projectDir), "archive") -} - -/** 全局 spec 目录:openspec/specs/,用于存放已归档变更的最终 spec */ -export function specsRoot(projectDir: string) { - return path.join(openspecRoot(projectDir), "specs") -} - -/** 某个变更的目录:openspec/changes// */ -export function changeDir(projectDir: string, name: string) { - return path.join(changesRoot(projectDir), slugify(name)) -} - -/** 归档变更的目录(无日期前缀):openspec/changes/archive// */ -export function archiveChangeDir(projectDir: string, name: string) { - return path.join(archiveRoot(projectDir), slugify(name)) -} - -/** 带日期前缀的归档目录:openspec/changes/archive/-/ */ -export function datedArchiveChangeDir(projectDir: string, name: string, archivedAt: Date | string) { - const datePrefix = typeof archivedAt === "string" ? archivedAt.slice(0, 10) : archivedAt.toISOString().slice(0, 10) - return path.join(archiveRoot(projectDir), `${datePrefix}-${slugify(name)}`) -} - -/** 变更的 proposal.md 路径 */ -export function proposalPath(projectDir: string, name: string) { - return path.join(changeDir(projectDir, name), "proposal.md") -} - -/** 变更的 design.md 路径 */ -export function designPath(projectDir: string, name: string) { - return path.join(changeDir(projectDir, name), "design.md") -} - -/** 变更的 tasks.md 路径 */ -export function tasksPath(projectDir: string, name: string) { - return path.join(changeDir(projectDir, name), "tasks.md") -} - -/** 变更的 specs 子目录:openspec/changes//specs/ */ -export function changeSpecsDir(projectDir: string, name: string) { - return path.join(changeDir(projectDir, name), "specs") -} - -/** 用户自定义模板目录:.opencode/opencode-spec/templates/ */ -export function pluginTemplateDir(projectDir: string) { - return path.join(projectDir, ".opencode", PLUGIN_ID, "templates") -} - -/** 项目配置文件路径:openspec/config.yaml */ -export function projectConfigPath(projectDir: string) { - return path.join(openspecRoot(projectDir), "config.yaml") -} - -/** - * 确保 OpenSpec 目录结构存在,如果缺失则创建 - * - * 创建的目录包括:openspec/specs/、openspec/changes/、openspec/changes/archive/ - * 返回实际创建的相对路径列表。 - */ -export async function ensureOpenSpecStructure(projectDir: string) { - const targets = [specsRoot(projectDir), changesRoot(projectDir), archiveRoot(projectDir)] - const created: string[] = [] - - for (const target of targets) { - if (!(await pathExists(target))) { - await mkdir(target, { recursive: true }) - created.push(toRelativePath(projectDir, target)) - } - } - - return created -} +export * from "../util/paths.js" diff --git a/src/core/config.ts b/src/core/project/config.ts similarity index 87% rename from src/core/config.ts rename to src/core/project/config.ts index 34d3232..c3c12e3 100644 --- a/src/core/config.ts +++ b/src/core/project/config.ts @@ -1,8 +1,8 @@ import { parse } from "yaml" -import { readOptionalText } from "./fs.js" -import { projectConfigPath, toRelativePath } from "./paths.js" -import type { BuiltinSchemaName } from "./types.js" +import { readOptionalText } from "../../util/fs.js" +import { projectConfigPath, toRelativePath } from "../../util/paths.js" +import type { BuiltinSchemaName } from "../model/types.js" /** OpenSpec 项目配置结构 */ export interface OpenSpecProjectConfig { diff --git a/src/core/init.ts b/src/core/project/init.ts similarity index 87% rename from src/core/init.ts rename to src/core/project/init.ts index d4d5378..9e2b03d 100644 --- a/src/core/init.ts +++ b/src/core/project/init.ts @@ -1,4 +1,4 @@ -import { ensureOpenSpecStructure } from "./paths.js" +import { ensureOpenSpecStructure } from "../../util/paths.js" export interface InitializeOpenSpecInput { projectDir: string @@ -17,4 +17,4 @@ export async function initializeOpenSpec(input: InitializeOpenSpecInput) { created, projectDir: input.projectDir, } -} \ No newline at end of file +} diff --git a/src/core/apply.ts b/src/core/workflow/apply.ts similarity index 95% rename from src/core/apply.ts rename to src/core/workflow/apply.ts index 88e4cd5..c709620 100644 --- a/src/core/apply.ts +++ b/src/core/workflow/apply.ts @@ -8,8 +8,8 @@ import { readOptionalText, slugify, writeText, -} from "./common.js" -import { toRelativePath } from "./paths.js" +} from "../../util/common.js" +import { toRelativePath } from "../../util/paths.js" export interface PrepareApplyInput { projectDir: string diff --git a/src/core/continue.ts b/src/core/workflow/continue.ts similarity index 76% rename from src/core/continue.ts rename to src/core/workflow/continue.ts index 0e2942b..d1655fa 100644 --- a/src/core/continue.ts +++ b/src/core/workflow/continue.ts @@ -1,12 +1,12 @@ -import { getSchema } from "./schema.js" -import { resolveChangeMeta } from "./change.js" -import { getArtifactStatus } from "./status.js" -import { updateDesign } from "./design.js" -import { updateProposal } from "./proposal.js" -import { updateTasks } from "./tasks.js" -import { updateSpecs } from "./specs.js" -import type { ArtifactId } from "./types.js" -import { getArtifactInstructions } from "./instructions.js" +import { getSchema } from "../model/schema.js" +import { resolveChangeMeta } from "../change/change.js" +import { getArtifactStatus } from "../change/status.js" +import { updateDesign } from "../artifact/design.js" +import { updateProposal } from "../artifact/proposal.js" +import { updateTasks } from "../artifact/tasks.js" +import { updateSpecs } from "../artifact/specs.js" +import type { ArtifactId } from "../model/types.js" +import { getArtifactInstructions } from "../artifact/instructions.js" export interface ContinueChangeInput { projectDir: string diff --git a/src/core/ff.ts b/src/core/workflow/ff.ts similarity index 89% rename from src/core/ff.ts rename to src/core/workflow/ff.ts index 802301e..461a150 100644 --- a/src/core/ff.ts +++ b/src/core/workflow/ff.ts @@ -1,6 +1,6 @@ -import { getSchema } from "./schema.js" -import { resolveChangeMeta } from "./change.js" -import { getArtifactStatus } from "./status.js" +import { getSchema } from "../model/schema.js" +import { resolveChangeMeta } from "../change/change.js" +import { getArtifactStatus } from "../change/status.js" import { continueChange } from "./continue.js" export interface FastForwardChangeInput { diff --git a/src/core/propose.ts b/src/core/workflow/propose.ts similarity index 89% rename from src/core/propose.ts rename to src/core/workflow/propose.ts index ab5544c..49b3b1e 100644 --- a/src/core/propose.ts +++ b/src/core/workflow/propose.ts @@ -1,9 +1,9 @@ import { fastForwardChange } from "./ff.js" -import { createChangeScaffold } from "./new.js" -import { updateDesign } from "./design.js" -import { updateProposal } from "./proposal.js" -import { updateSpecs } from "./specs.js" -import { updateTasks } from "./tasks.js" +import { createChangeScaffold } from "../change/new.js" +import { updateDesign } from "../artifact/design.js" +import { updateProposal } from "../artifact/proposal.js" +import { updateSpecs } from "../artifact/specs.js" +import { updateTasks } from "../artifact/tasks.js" export interface ProposeChangeInput { projectDir: string diff --git a/src/core/validate.ts b/src/core/workflow/validate.ts similarity index 94% rename from src/core/validate.ts rename to src/core/workflow/validate.ts index dbd5f83..a8cbbcc 100644 --- a/src/core/validate.ts +++ b/src/core/workflow/validate.ts @@ -1,8 +1,8 @@ import path from "node:path" -import { resolveChangeLocation } from "./change.js" -import { listFilesRecursive, pathExists, readOptionalText } from "./fs.js" -import { parseTasks, validateTasksMarkdown } from "./tasks-format.js" +import { resolveChangeLocation } from "../change/change.js" +import { listFilesRecursive, pathExists, readOptionalText } from "../../util/fs.js" +import { parseTasks, validateTasksMarkdown } from "../../util/tasks-format.js" export interface ValidateChangeInput { projectDir: string diff --git a/src/core/verify.ts b/src/core/workflow/verify.ts similarity index 93% rename from src/core/verify.ts rename to src/core/workflow/verify.ts index 6a179d9..02f6070 100644 --- a/src/core/verify.ts +++ b/src/core/workflow/verify.ts @@ -1,5 +1,5 @@ -import { readOptionalText } from "./fs.js" -import { parseTasks } from "./tasks-format.js" +import { readOptionalText } from "../../util/fs.js" +import { parseTasks } from "../../util/tasks-format.js" import { validateChange } from "./validate.js" export interface VerifyChangeInput { diff --git a/src/core/archive.ts b/src/sync/archive.ts similarity index 90% rename from src/core/archive.ts rename to src/sync/archive.ts index f4f1318..84cd817 100644 --- a/src/core/archive.ts +++ b/src/sync/archive.ts @@ -1,14 +1,14 @@ import { rename } from "node:fs/promises" import path from "node:path" -import { datedArchiveChangeDir, toRelativePath } from "./paths.js" +import { datedArchiveChangeDir, toRelativePath } from "../util/paths.js" import { syncChangeSpecs } from "./sync.js" -import { verifyChange } from "./verify.js" +import { verifyChange } from "../core/workflow/verify.js" import { changeDir, pathExists, slugify, -} from "./common.js" +} from "../util/common.js" export interface ArchiveChangeInput { projectDir: string diff --git a/src/core/bulk-archive.ts b/src/sync/bulk-archive.ts similarity index 100% rename from src/core/bulk-archive.ts rename to src/sync/bulk-archive.ts diff --git a/src/core/sync.ts b/src/sync/sync.ts similarity index 86% rename from src/core/sync.ts rename to src/sync/sync.ts index b4c3a8f..6ef7767 100644 --- a/src/core/sync.ts +++ b/src/sync/sync.ts @@ -1,10 +1,10 @@ import { readFile } from "node:fs/promises" import path from "node:path" -import { resolveChangeLocation } from "./change.js" -import { listFilesRecursive, writeText } from "./fs.js" -import { specsRoot, toRelativePath } from "./paths.js" -import { validateChange } from "./validate.js" +import { resolveChangeLocation } from "../core/change/change.js" +import { listFilesRecursive, writeText } from "../util/fs.js" +import { specsRoot, toRelativePath } from "../util/paths.js" +import { validateChange } from "../core/workflow/validate.js" export interface SyncChangeSpecsInput { projectDir: string diff --git a/src/core/common.ts b/src/util/common.ts similarity index 100% rename from src/core/common.ts rename to src/util/common.ts diff --git a/src/core/fs.ts b/src/util/fs.ts similarity index 100% rename from src/core/fs.ts rename to src/util/fs.ts diff --git a/src/util/paths.ts b/src/util/paths.ts new file mode 100644 index 0000000..826f768 --- /dev/null +++ b/src/util/paths.ts @@ -0,0 +1,166 @@ +import { mkdir } from "node:fs/promises" +import path from "node:path" + +import { pathExists } from "./fs.js" + +/** OpenSpec 输出目录名,默认为 "openspec",可通过插件 options 自定义 */ +let _openspecDir = "openspec" + +/** 设置 OpenSpec 输出目录名 */ +export function setOpenspecDir(dir: string) { + const trimmed = dir.trim() + if (!trimmed) throw new Error("OpenSpec 目录名不能为空") + _openspecDir = trimmed +} + +/** 获取当前 OpenSpec 输出目录名 */ +export function getOpenspecDir() { + return _openspecDir +} + +/** OpenSpec 插件的唯一标识 */ +export const PLUGIN_ID = "opencode-spec" + +const MAX_SLUG_LENGTH = 60 + +/** + * 将字符串转换为 URL 友好的 slug + * + * 规则:小写、去除非字母数字字符、连字符化、去首尾连字符、合并连续连字符。 + * 长度截断到 60 字符。 + */ +export function slugify(value: string) { + let slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/--+/g, "-") + + if (slug.length > MAX_SLUG_LENGTH) { + const sliced = slug.slice(0, MAX_SLUG_LENGTH) + const lastDash = sliced.lastIndexOf("-") + slug = lastDash > MAX_SLUG_LENGTH / 2 ? sliced.slice(0, lastDash) : sliced + } + + return slug || "change" +} + +/** + * 验证并生成变更 slug + * + * 严格校验输入必须是合理的英文 kebab-case 短语: + * - 禁止非 ASCII 字符(中文、日文、韩文等),必须翻译为英文 + * - slug 中至少含一个连字符(至少两个单词) + * - slug 中字母数字字符至少 4 个 + */ +export function validateSlug(value: string) { + const hasNonAscii = /[^\x00-\x7F]/.test(value) + if (hasNonAscii) { + throw new Error("变更名必须为英文 kebab-case 短语,请将中文翻译为英文后再试") + } + + const slug = slugify(value) + const alphaChars = slug.replace(/[^a-z0-9]/g, "") + + if (alphaChars.length < 4) { + throw new Error(`变更名过于简洁("${slug}"),请使用更具体的英文短语,例如 "add-user-auth" 而非 "add-auth"`) + } + + if (!slug.includes("-")) { + throw new Error(`变更名至少需要 2 个单词("${slug}"),请使用更具体的英文短语`) + } + + return slug +} + +/** 将绝对路径转换为相对于项目目录的路径(使用正斜杠) */ +export function toRelativePath(projectDir: string, targetPath: string) { + return path.relative(projectDir, targetPath).replace(/\\/g, "/") +} + +/** OpenSpec 根目录:项目根下的 openspec/(目录名可通过 setOpenspecDir 自定义) */ +export function openspecRoot(projectDir: string) { + return path.join(projectDir, _openspecDir) +} + +/** 变更目录:openspec/changes/ */ +export function changesRoot(projectDir: string) { + return path.join(openspecRoot(projectDir), "changes") +} + +/** 归档变更目录:openspec/changes/archive/ */ +export function archiveRoot(projectDir: string) { + return path.join(changesRoot(projectDir), "archive") +} + +/** 全局 spec 目录:openspec/specs/,用于存放已归档变更的最终 spec */ +export function specsRoot(projectDir: string) { + return path.join(openspecRoot(projectDir), "specs") +} + +/** 某个变更的目录:openspec/changes// */ +export function changeDir(projectDir: string, name: string) { + return path.join(changesRoot(projectDir), slugify(name)) +} + +/** 归档变更的目录(无日期前缀):openspec/changes/archive// */ +export function archiveChangeDir(projectDir: string, name: string) { + return path.join(archiveRoot(projectDir), slugify(name)) +} + +/** 带日期前缀的归档目录:openspec/changes/archive/-/ */ +export function datedArchiveChangeDir(projectDir: string, name: string, archivedAt: Date | string) { + const datePrefix = typeof archivedAt === "string" ? archivedAt.slice(0, 10) : archivedAt.toISOString().slice(0, 10) + return path.join(archiveRoot(projectDir), `${datePrefix}-${slugify(name)}`) +} + +/** 变更的 proposal.md 路径 */ +export function proposalPath(projectDir: string, name: string) { + return path.join(changeDir(projectDir, name), "proposal.md") +} + +/** 变更的 design.md 路径 */ +export function designPath(projectDir: string, name: string) { + return path.join(changeDir(projectDir, name), "design.md") +} + +/** 变更的 tasks.md 路径 */ +export function tasksPath(projectDir: string, name: string) { + return path.join(changeDir(projectDir, name), "tasks.md") +} + +/** 变更的 specs 子目录:openspec/changes//specs/ */ +export function changeSpecsDir(projectDir: string, name: string) { + return path.join(changeDir(projectDir, name), "specs") +} + +/** 用户自定义模板目录:.opencode/opencode-spec/templates/ */ +export function pluginTemplateDir(projectDir: string) { + return path.join(projectDir, ".opencode", PLUGIN_ID, "templates") +} + +/** 项目配置文件路径:openspec/config.yaml */ +export function projectConfigPath(projectDir: string) { + return path.join(openspecRoot(projectDir), "config.yaml") +} + +/** + * 确保 OpenSpec 目录结构存在,如果缺失则创建 + * + * 创建的目录包括:openspec/specs/、openspec/changes/、openspec/changes/archive/ + * 返回实际创建的相对路径列表。 + */ +export async function ensureOpenSpecStructure(projectDir: string) { + const targets = [specsRoot(projectDir), changesRoot(projectDir), archiveRoot(projectDir)] + const created: string[] = [] + + for (const target of targets) { + if (!(await pathExists(target))) { + await mkdir(target, { recursive: true }) + created.push(toRelativePath(projectDir, target)) + } + } + + return created +} diff --git a/src/core/tasks-format.ts b/src/util/tasks-format.ts similarity index 100% rename from src/core/tasks-format.ts rename to src/util/tasks-format.ts diff --git a/src/core/templates.ts b/src/util/templates.ts similarity index 100% rename from src/core/templates.ts rename to src/util/templates.ts From cfbc777a2b02dd9d2ccbba220ebf5589a9e8e0e7 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:39:18 +0800 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20plugin.ts?= =?UTF-8?q?=20=E4=B8=BA=20server/commands/bootstrap/skills=EF=BC=8C?= =?UTF-8?q?=E4=BF=9D=E7=95=99=20packageRoot=20=E9=94=9A=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin.ts | 222 +--------------------------------------- src/plugin/bootstrap.ts | 25 +++++ src/plugin/commands.ts | 81 +++++++++++++++ src/plugin/server.ts | 83 +++++++++++++++ src/plugin/skills.ts | 40 ++++++++ 5 files changed, 231 insertions(+), 220 deletions(-) create mode 100644 src/plugin/bootstrap.ts create mode 100644 src/plugin/commands.ts create mode 100644 src/plugin/server.ts create mode 100644 src/plugin/skills.ts diff --git a/src/plugin.ts b/src/plugin.ts index 4c6fc7b..52297fb 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,10 +1,7 @@ import type { Plugin } from "@opencode-ai/plugin" -import { cp, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" -import { existsSync, readdirSync, readFileSync } from "node:fs" -import { tmpdir } from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" -import { getOpenspecDir, setOpenspecDir } from "./core/paths.js" +import { createOpencodeSpec } from "./plugin/server.js" /** 根据 import.meta.url 解析插件包的根目录路径 */ export function resolvePackageRoot(metaUrl: string) { @@ -14,219 +11,4 @@ export function resolvePackageRoot(metaUrl: string) { /** 插件包的根目录 */ const packageRoot = resolvePackageRoot(import.meta.url) -/** - * 解析并剥离 Markdown 文件的 frontmatter - * - * Frontmatter 是位于文件开头的 --- 包裹的 YAML 格式元数据, - * 例如:---\ntitle: foo\n---\n正文内容 - */ -function extractAndStripFrontmatter(content: string) { - const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/) - if (!match) return { frontmatter: {} as Record, content } - - const frontmatterStr = match[1] - const body = match[2] - const frontmatter: Record = {} - - for (const line of frontmatterStr.split("\n")) { - const colonIdx = line.indexOf(":") - if (colonIdx > 0) { - const key = line.slice(0, colonIdx).trim() - const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, "") - frontmatter[key] = value - } - } - - return { frontmatter, content: body } -} - -/** 解析后的 slash command 结构 */ -interface ParsedCommand { - name: string - description?: string - agent?: string - model?: string - subtask?: boolean - template: string -} - -/** commands 缓存,避免重复读取磁盘 */ -let _commandsCache: { key: string; value: ParsedCommand[] } | undefined - -/** - * 从 commandsDir 加载所有 slash command - * - * 遍历目录下的所有 .md 文件,解析 frontmatter 获取命令的元数据, - * 并将 skills 路径替换为运行时动态创建的 skills 目录。 - */ -function loadCommands(commandsDir: string, skillsDir: string): ParsedCommand[] { - const cacheKey = `${commandsDir}:${skillsDir}` - if (_commandsCache?.key === cacheKey) return _commandsCache.value - - if (!existsSync(commandsDir)) { - _commandsCache = { key: cacheKey, value: [] } - return [] - } - - const parsed: ParsedCommand[] = [] - - for (const file of readdirSync(commandsDir)) { - if (!file.endsWith(".md")) continue - - const name = path.basename(file, ".md") - const raw = readFileSync(path.join(commandsDir, file), "utf8") - const { frontmatter, content } = extractAndStripFrontmatter(raw) - - const template = content.trim().replaceAll(".opencode/skills/", `${skillsDir}/`) - - parsed.push({ - name, - description: frontmatter.description, - agent: frontmatter.agent, - model: frontmatter.model, - subtask: frontmatter.subtask === "true" ? true : frontmatter.subtask === "false" ? false : undefined, - template, - }) - } - - _commandsCache = { key: cacheKey, value: parsed } - return parsed -} - -/** bootstrap 消息缓存 */ -let _bootstrapCache: string | undefined - -/** - * 生成每次对话注入的 bootstrap 提示消息 - * - * 该消息告知用户 OpenSpec 工作流已启用以及可用的 slash command。 - */ -function getBootstrapContent(): string { - if (_bootstrapCache !== undefined) return _bootstrapCache - - _bootstrapCache = ` -OpenSpec 工作流已启用。 - -你可以使用以下 slash commands: -- /opsx-propose <变更名> — 创建完整的 proposal/specs/design/tasks -- /opsx-apply <变更名> — 执行 tasks 实现变更 -- /opsx-archive <变更名> — 归档已完成变更 -- /opsx-explore <主题> — 需求澄清与方案探索 - -推荐流程:proposal → specs → design → tasks → apply → archive -` - - return _bootstrapCache -} - -/** - * 在临时目录中复制并处理 skills 目录 - * - * SKILL.md 中的路径占位符 .opencode/skills/ 会被替换为实际的临时目录路径。 - * 进程退出时自动清理临时目录。 - */ -async function setupSkillsDir(sourceSkillsDir: string): Promise { - const baseDir = await mkdtemp(path.join(tmpdir(), "opencode-spec-skills-")) - const destDir = path.join(baseDir, "skills") - - await cp(sourceSkillsDir, destDir, { recursive: true }) - - async function processDir(dir: string) { - const entries = await readdir(dir, { withFileTypes: true }) - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - await processDir(fullPath) - continue - } - if (entry.name === "SKILL.md") { - let content = await readFile(fullPath, "utf8") - content = content.replaceAll(".opencode/skills/", `${destDir}/`) - await writeFile(fullPath, content, "utf8") - } - } - } - - await processDir(destDir) - - process.on("exit", () => { - rm(baseDir, { recursive: true, force: true }).catch(() => {}) - }) - - return destDir -} - -/** - * OpenSpec 插件工厂函数 - * - * @param ctx - OpenCode 插件上下文 - * @param options - 插件配置选项,支持 `directory` 字段自定义输出目录(默认 "openspec") - * - * 注册两个钩子: - * 1. config - 在启动时注入 skills 路径和 slash commands,并读取 openspec.directory 配置 - * 2. experimental.chat.messages.transform - 在每条用户消息前插入 bootstrap 提示 - */ -export const OpencodeSpec: Plugin = async (ctx, options) => { - if (options?.directory && typeof options.directory === "string" && options.directory.trim()) { - const dir = options.directory.trim() - setOpenspecDir(dir) - process.env.OPENSPEC_DIR = dir - } - - const sourceSkillsDir = path.join(packageRoot, "assets", "skills") - const commandsDir = path.join(packageRoot, "assets", "commands") - const skillsDir = await setupSkillsDir(sourceSkillsDir) - - return { - /** - * 配置钩子:将 OpenSpec 的 skills 目录和 slash commands 注入到 OpenCode 配置中 - */ - config: async (rawConfig) => { - const config = rawConfig as Record - - if (getOpenspecDir() === "openspec") { - const openspecConfig = config.openspec as Record | undefined - if (openspecConfig?.directory && typeof openspecConfig.directory === "string" && openspecConfig.directory.trim()) { - const dir = openspecConfig.directory.trim() - setOpenspecDir(dir) - process.env.OPENSPEC_DIR = dir - } - } - - config.skills = config.skills || {} - config.skills.paths = config.skills.paths || [] - if (!config.skills.paths.includes(skillsDir)) { - config.skills.paths.push(skillsDir) - } - - config.command = config.command || {} - for (const cmd of loadCommands(commandsDir, skillsDir)) { - if (config.command[cmd.name]) continue - config.command[cmd.name] = { - template: cmd.template, - description: cmd.description, - agent: cmd.agent, - model: cmd.model, - subtask: cmd.subtask, - } - } - }, - - /** - * 消息转换钩子:在第一条用户消息前插入 bootstrap 提示 - * - * 如果 bootstrap 已存在(如已被其他插件或历史注入),则跳过。 - */ - "experimental.chat.messages.transform": async (_input, output) => { - const bootstrap = getBootstrapContent() - if (!output.messages.length) return - - const firstUser = output.messages.find(m => m.info.role === "user") - if (!firstUser || !firstUser.parts.length) return - - if (firstUser.parts.some(p => p.type === "text" && p.text.includes("EXTREMELY_IMPORTANT"))) return - - firstUser.parts.unshift({ type: "text", text: bootstrap } as typeof firstUser.parts[number]) - }, - } -} +export const OpencodeSpec: Plugin = createOpencodeSpec(packageRoot) diff --git a/src/plugin/bootstrap.ts b/src/plugin/bootstrap.ts new file mode 100644 index 0000000..55d4ac6 --- /dev/null +++ b/src/plugin/bootstrap.ts @@ -0,0 +1,25 @@ +/** bootstrap 消息缓存 */ +let _bootstrapCache: string | undefined + +/** + * 生成每次对话注入的 bootstrap 提示消息 + * + * 该消息告知用户 OpenSpec 工作流已启用以及可用的 slash command。 + */ +export function getBootstrapContent(): string { + if (_bootstrapCache !== undefined) return _bootstrapCache + + _bootstrapCache = ` +OpenSpec 工作流已启用。 + +你可以使用以下 slash commands: +- /opsx-propose <变更名> — 创建完整的 proposal/specs/design/tasks +- /opsx-apply <变更名> — 执行 tasks 实现变更 +- /opsx-archive <变更名> — 归档已完成变更 +- /opsx-explore <主题> — 需求澄清与方案探索 + +推荐流程:proposal → specs → design → tasks → apply → archive +` + + return _bootstrapCache +} diff --git a/src/plugin/commands.ts b/src/plugin/commands.ts new file mode 100644 index 0000000..e025837 --- /dev/null +++ b/src/plugin/commands.ts @@ -0,0 +1,81 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import path from "node:path" + +/** + * 解析并剥离 Markdown 文件的 frontmatter + * + * Frontmatter 是位于文件开头的 --- 包裹的 YAML 格式元数据, + * 例如:---\ntitle: foo\n---\n正文内容 + */ +function extractAndStripFrontmatter(content: string) { + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/) + if (!match) return { frontmatter: {} as Record, content } + + const frontmatterStr = match[1] + const body = match[2] + const frontmatter: Record = {} + + for (const line of frontmatterStr.split("\n")) { + const colonIdx = line.indexOf(":") + if (colonIdx > 0) { + const key = line.slice(0, colonIdx).trim() + const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, "") + frontmatter[key] = value + } + } + + return { frontmatter, content: body } +} + +/** 解析后的 slash command 结构 */ +export interface ParsedCommand { + name: string + description?: string + agent?: string + model?: string + subtask?: boolean + template: string +} + +/** commands 缓存,避免重复读取磁盘 */ +let _commandsCache: { key: string; value: ParsedCommand[] } | undefined + +/** + * 从 commandsDir 加载所有 slash command + * + * 遍历目录下的所有 .md 文件,解析 frontmatter 获取命令的元数据, + * 并将 skills 路径替换为运行时动态创建的 skills 目录。 + */ +export function loadCommands(commandsDir: string, skillsDir: string): ParsedCommand[] { + const cacheKey = `${commandsDir}:${skillsDir}` + if (_commandsCache?.key === cacheKey) return _commandsCache.value + + if (!existsSync(commandsDir)) { + _commandsCache = { key: cacheKey, value: [] } + return [] + } + + const parsed: ParsedCommand[] = [] + + for (const file of readdirSync(commandsDir)) { + if (!file.endsWith(".md")) continue + + const name = path.basename(file, ".md") + const raw = readFileSync(path.join(commandsDir, file), "utf8") + const { frontmatter, content } = extractAndStripFrontmatter(raw) + + const template = content.trim().replaceAll(".opencode/skills/", `${skillsDir}/`) + + parsed.push({ + name, + description: frontmatter.description, + agent: frontmatter.agent, + model: frontmatter.model, + subtask: frontmatter.subtask === "true" ? true : frontmatter.subtask === "false" ? false : undefined, + template, + }) + } + + _commandsCache = { key: cacheKey, value: parsed } + return parsed +} diff --git a/src/plugin/server.ts b/src/plugin/server.ts new file mode 100644 index 0000000..e167f6f --- /dev/null +++ b/src/plugin/server.ts @@ -0,0 +1,83 @@ +import type { Plugin } from "@opencode-ai/plugin" +import path from "node:path" + +import { getOpenspecDir, setOpenspecDir } from "../util/paths.js" +import { getBootstrapContent } from "./bootstrap.js" +import { loadCommands } from "./commands.js" +import { setupSkillsDir } from "./skills.js" + +/** + * OpenSpec 插件工厂函数 + * + * @param packageRoot - 插件包根目录,用于定位 assets 资源。 + * + * 注册两个钩子: + * 1. config - 在启动时注入 skills 路径和 slash commands,并读取 openspec.directory 配置 + * 2. experimental.chat.messages.transform - 在每条用户消息前插入 bootstrap 提示 + */ +export function createOpencodeSpec(packageRoot: string): Plugin { + return async (_ctx, options) => { + if (options?.directory && typeof options.directory === "string" && options.directory.trim()) { + const dir = options.directory.trim() + setOpenspecDir(dir) + process.env.OPENSPEC_DIR = dir + } + + const sourceSkillsDir = path.join(packageRoot, "assets", "skills") + const commandsDir = path.join(packageRoot, "assets", "commands") + const skillsDir = await setupSkillsDir(sourceSkillsDir) + + return { + /** + * 配置钩子:将 OpenSpec 的 skills 目录和 slash commands 注入到 OpenCode 配置中 + */ + config: async (rawConfig) => { + const config = rawConfig as Record + + if (getOpenspecDir() === "openspec") { + const openspecConfig = config.openspec as Record | undefined + if (openspecConfig?.directory && typeof openspecConfig.directory === "string" && openspecConfig.directory.trim()) { + const dir = openspecConfig.directory.trim() + setOpenspecDir(dir) + process.env.OPENSPEC_DIR = dir + } + } + + config.skills = config.skills || {} + config.skills.paths = config.skills.paths || [] + if (!config.skills.paths.includes(skillsDir)) { + config.skills.paths.push(skillsDir) + } + + config.command = config.command || {} + for (const cmd of loadCommands(commandsDir, skillsDir)) { + if (config.command[cmd.name]) continue + config.command[cmd.name] = { + template: cmd.template, + description: cmd.description, + agent: cmd.agent, + model: cmd.model, + subtask: cmd.subtask, + } + } + }, + + /** + * 消息转换钩子:在第一条用户消息前插入 bootstrap 提示 + * + * 如果 bootstrap 已存在(如已被其他插件或历史注入),则跳过。 + */ + "experimental.chat.messages.transform": async (_input, output) => { + const bootstrap = getBootstrapContent() + if (!output.messages.length) return + + const firstUser = output.messages.find(m => m.info.role === "user") + if (!firstUser || !firstUser.parts.length) return + + if (firstUser.parts.some(p => p.type === "text" && p.text.includes("EXTREMELY_IMPORTANT"))) return + + firstUser.parts.unshift({ type: "text", text: bootstrap } as typeof firstUser.parts[number]) + }, + } + } +} diff --git a/src/plugin/skills.ts b/src/plugin/skills.ts new file mode 100644 index 0000000..cddd42e --- /dev/null +++ b/src/plugin/skills.ts @@ -0,0 +1,40 @@ +import { cp, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" + +/** + * 在临时目录中复制并处理 skills 目录 + * + * SKILL.md 中的路径占位符 .opencode/skills/ 会被替换为实际的临时目录路径。 + * 进程退出时自动清理临时目录。 + */ +export async function setupSkillsDir(sourceSkillsDir: string): Promise { + const baseDir = await mkdtemp(path.join(tmpdir(), "opencode-spec-skills-")) + const destDir = path.join(baseDir, "skills") + + await cp(sourceSkillsDir, destDir, { recursive: true }) + + async function processDir(dir: string) { + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + await processDir(fullPath) + continue + } + if (entry.name === "SKILL.md") { + let content = await readFile(fullPath, "utf8") + content = content.replaceAll(".opencode/skills/", `${destDir}/`) + await writeFile(fullPath, content, "utf8") + } + } + } + + await processDir(destDir) + + process.on("exit", () => { + rm(baseDir, { recursive: true, force: true }).catch(() => {}) + }) + + return destDir +} From 9f53177bab7b354e62a8318f0af03e6ca76fa592 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:50:40 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(env):=20=E4=BF=AE=E5=A4=8D=20OPENSPEC?= =?UTF-8?q?=5FDIR=20=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E5=8F=AA=E5=86=99?= =?UTF-8?q?=E4=B8=8D=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 优先级链:env > options > config > default - server.ts: 在 createOpencodeSpec() 入口处先读取 process.env.OPENSPEC_DIR - options.directory 仅在 env var 未设置时才调用 setOpenspecDir - test/plugin.test.ts: 添加环境变量读取测试,afterEach 清理 env var 防止泄漏 --- ...6-06-13-issue-2-fix-env-var-paths-brief.md | 40 +++++++ docs/diagnosis-issue-2.md | 113 ++++++++++++++++++ src/plugin/server.ts | 10 +- test/plugin.test.ts | 19 +++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 docs/2026-06-13-issue-2-fix-env-var-paths-brief.md create mode 100644 docs/diagnosis-issue-2.md diff --git a/docs/2026-06-13-issue-2-fix-env-var-paths-brief.md b/docs/2026-06-13-issue-2-fix-env-var-paths-brief.md new file mode 100644 index 0000000..3ad2893 --- /dev/null +++ b/docs/2026-06-13-issue-2-fix-env-var-paths-brief.md @@ -0,0 +1,40 @@ +## Agent Brief + +**类别:** bug +**摘要:** 修复 OPENSPEC_DIR 环境变量只写不读的 bug,以及 design.ts/tasks.ts 硬编码路径的不一致 + +### 问题 1:OPENSPEC_DIR 环境变量只写不读 + +**当前行为:** +`src/plugin/server.ts` 中 `process.env.OPENSPEC_DIR` 仅被写入(`options.directory` 和 `config.openspec.directory` 设置时),但核心模块 `src/util/paths.ts` 的 `openspecRoot()` 使用模块级变量 `_openspecDir`,从不读取环境变量。README 声明的「可通过环境变量 OPENSPEC_DIR 指定,优先级高于配置」未实现。 + +**期望行为:** +在 `createOpencodeSpec()` 入口处,先检查 `process.env.OPENSPEC_DIR` 并调用 `setOpenspecDir()`。优先级链:**env > options > config > default**。 + +**关键接口:** +- `setOpenspecDir(dir: string)` — 新增环境变量读取路径 +- `getOpenspecDir()` — 返回值应正确反映环境变量设置 +- `openspecRoot(projectDir)` — 使用 `_openspecDir`,由 `setOpenspecDir` 设置 + +### 问题 2:design.ts / tasks.ts 硬编码路径 + +**当前行为:** +`src/core/artifact/design.ts:20` 使用 `path.join(targetDir, "design.md")`,`src/core/artifact/tasks.ts:21` 使用 `path.join(targetDir, "tasks.md")`,而 `src/util/paths.ts` 已定义 `designPath()` 和 `tasksPath()`。 + +**期望行为:** +- `design.ts` 使用 `designPath(input.projectDir, slug)` 生成路径 +- `tasks.ts` 使用 `tasksPath(input.projectDir, slug)` 生成路径 + +**验收标准:** +- [ ] 设置 `OPENSPEC_DIR` 环境变量后,`getOpenspecDir()` 返回环境变量指定的值 +- [ ] 环境变量优先级高于 `options.directory` 和 `config.openspec.directory` +- [ ] 未设置环境变量时,`options.directory` 和 `config.openspec.directory` 正常生效 +- [ ] 环境变量、options、config 均未设置时,使用默认值 "openspec" +- [ ] `design.ts` 使用 `designPath()` 函数生成路径 +- [ ] `tasks.ts` 使用 `tasksPath()` 函数生成路径 +- [ ] 所有测试通过(基线 60/62 passed,2 个 reference-scripts 失败为已有问题) +- [ ] 无回归 + +**不在范围内:** +- `src/core/workflow/validate.ts` 和 `src/core/change/list.ts` 中类似的硬编码路径问题 +- 技能参考脚本 `assets/skills/_shared/references/openspec.js` 中的任何变更 diff --git a/docs/diagnosis-issue-2.md b/docs/diagnosis-issue-2.md new file mode 100644 index 0000000..236a377 --- /dev/null +++ b/docs/diagnosis-issue-2.md @@ -0,0 +1,113 @@ +## 诊断报告 + +### 发现 + +Issue 报告的两个问题均确认存在,属于代码审查级别的 bug: + +1. **OPENSPEC_DIR 环境变量只写不读**:`src/plugin/server.ts` 中 `process.env.OPENSPEC_DIR` 仅被写入(line 23, 42),TypeScript 核心代码(`src/`)从未读取该环境变量。README 声明的"也可通过环境变量 OPENSPEC_DIR 指定,优先级高于配置"功能未在核心模块中实现。 + +2. **design.ts / tasks.ts 硬编码路径**:`src/core/artifact/design.ts` 和 `src/core/artifact/tasks.ts` 使用 `path.join(targetDir, "design.md")` / `path.join(targetDir, "tasks.md")` 硬编码文件名,而 `src/util/paths.ts` 中已定义了 `designPath()` 和 `tasksPath()` 函数,且 `proposal.ts` 已正确使用 `proposalPath()`。 + +### 原因 + +#### Bug 1:OPENSPEC_DIR 环境变量 + +**写入位置**(`src/plugin/server.ts`): +- Line 23:`options.directory` 设置时写入 `process.env.OPENSPEC_DIR` +- Line 42:`config.openspec.directory` 设置时写入 `process.env.OPENSPEC_DIR` + +**读取位置**:仅在 `assets/skills/_shared/references/openspec.js`(技能参考脚本)的 `openspecRoot()` 函数(line 486)中读取: +```js +export function openspecRoot(projectDir = projectRoot) { + return path.join(projectDir, process.env.OPENSPEC_DIR || "openspec") +} +``` + +**核心模块**(`src/util/paths.ts`)的 `openspecRoot()` 函数使用模块级变量 `_openspecDir`,该变量仅通过 `setOpenspecDir()` 设置,从不读取环境变量: +```ts +let _openspecDir = "openspec" + +export function setOpenspecDir(dir: string) { ... } +export function getOpenspecDir() { return _openspecDir } + +export function openspecRoot(projectDir: string) { + return path.join(projectDir, _openspecDir) +} +``` + +**根因**:插件工厂函数 `createOpencodeSpec()` 在启动时没有检查 `process.env.OPENSPEC_DIR` 并调用 `setOpenspecDir()`。当前优先级链为 `options > config > default`,缺少 `env` 这一层。 + +#### Bug 2:硬编码路径 + +**`src/core/artifact/design.ts`** line 20: +```ts +const filePath = path.join(targetDir, "design.md") +``` +应改为使用已定义的 `designPath()`: +```ts +import { designPath } from "../../util/paths.js" +const filePath = designPath(input.projectDir, slug) +``` + +**`src/core/artifact/tasks.ts`** line 21: +```ts +const filePath = path.join(targetDir, "tasks.md") +``` +应改为使用已定义的 `tasksPath()`: +```ts +import { tasksPath } from "../../util/paths.js" +const filePath = tasksPath(input.projectDir, slug) +``` + +**对比**:`src/core/artifact/proposal.ts` line 21 已正确使用 `proposalPath()`。 + +**额外发现**:`src/core/workflow/validate.ts`(lines 44-45)和 `src/core/change/list.ts`(line 31)也存在类似的硬编码,但 Issue 未要求修复这些位置。 + +### 修复建议 + +#### 修复 1:OPENSPEC_DIR 环境变量读取 + +在 `src/plugin/server.ts` 的 `createOpencodeSpec()` 函数开头,`options.directory` 检查之前,增加环境变量检查: + +```ts +export function createOpencodeSpec(packageRoot: string): Plugin { + return async (_ctx, options) => { + // 优先级:env > options > config > default + if (process.env.OPENSPEC_DIR?.trim()) { + setOpenspecDir(process.env.OPENSPEC_DIR.trim()) + } + + if (options?.directory && typeof options.directory === "string" && options.directory.trim()) { + const dir = options.directory.trim() + setOpenspecDir(dir) + process.env.OPENSPEC_DIR = dir + } + // ... 其余逻辑不变 +``` + +注意:`options.directory` 设置时仍需同步写入 `process.env.OPENSPEC_DIR`,以确保技能参考脚本(`assets/skills/_shared/references/openspec.js`)也能读到正确的目录名。 + +#### 修复 2:design.ts / tasks.ts 使用 paths 函数 + +**`src/core/artifact/design.ts`**: +- 新增 import:`import { designPath } from "../../util/paths.js"` +- 将 `path.join(targetDir, "design.md")` 替换为 `designPath(input.projectDir, slug)` + +**`src/core/artifact/tasks.ts`**: +- 新增 import:`import { tasksPath } from "../../util/paths.js"` +- 将 `path.join(targetDir, "tasks.md")` 替换为 `tasksPath(input.projectDir, slug)` + +### 验证方法 + +1. **Bug 1 验证**: + - 设置 `OPENSPEC_DIR=custom-dir` 环境变量后启动插件 + - 调用 `initializeOpenSpec()` 应创建 `custom-dir/specs/`、`custom-dir/changes/` 等目录 + - 未设置环境变量时,`options.directory` 和 `config.openspec.directory` 正常生效 + - 均未设置时使用默认值 `openspec` + +2. **Bug 2 验证**: + - 运行现有测试套件(`npx vitest run`),所有与 design/tasks 相关的测试应通过 + - 自定义 `OPENSPEC_DIR` 后调用 `updateDesign()` / `updateTasks()`,文件应写入正确的自定义目录 + +3. **回归验证**: + - `npx vitest run` 全部通过(当前基线:60/62 passed,2 个 reference-scripts 测试失败为已有问题,非本次修复引入) diff --git a/src/plugin/server.ts b/src/plugin/server.ts index e167f6f..223fed9 100644 --- a/src/plugin/server.ts +++ b/src/plugin/server.ts @@ -17,9 +17,17 @@ import { setupSkillsDir } from "./skills.js" */ export function createOpencodeSpec(packageRoot: string): Plugin { return async (_ctx, options) => { + // 优先级:env > options > config > default + const envDir = process.env.OPENSPEC_DIR?.trim() + if (envDir) { + setOpenspecDir(envDir) + } + if (options?.directory && typeof options.directory === "string" && options.directory.trim()) { const dir = options.directory.trim() - setOpenspecDir(dir) + if (!process.env.OPENSPEC_DIR?.trim()) { + setOpenspecDir(dir) + } process.env.OPENSPEC_DIR = dir } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 4d7c0c6..d468231 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -11,6 +11,7 @@ const tempDirs: string[] = [] afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) + delete process.env.OPENSPEC_DIR setOpenspecDir("openspec") }) @@ -267,4 +268,22 @@ describe("OpencodeSpec custom directory option", () => { expect(getOpenspecDir()).toBe("openspec") expect(plugin).toBeDefined() }) + + it("OPENSPEC_DIR 环境变量设置后 getOpenspecDir 返回环境变量值", async () => { + const projectDir = await makeTempDir() + await mkdir(path.join(projectDir, "assets", "skills", "openspec-propose"), { recursive: true }) + await mkdir(path.join(projectDir, "assets", "commands"), { recursive: true }) + await writeFile(path.join(projectDir, "package.json"), JSON.stringify({ version: "0.1.0" })) + + process.env.OPENSPEC_DIR = "env-spec-dir" + try { + const plugin = await OpencodeSpec( + { client: {} as never, directory: projectDir, worktree: projectDir } as never, + ) + expect(getOpenspecDir()).toBe("env-spec-dir") + expect(plugin).toBeDefined() + } finally { + delete process.env.OPENSPEC_DIR + } + }) }) From a6c345a5e44f10093dd5ba77168d967825cc45e9 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:52:06 +0800 Subject: [PATCH 4/7] =?UTF-8?q?test(env):=20=E6=B7=BB=E5=8A=A0=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E4=BC=98=E5=85=88=E7=BA=A7=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证 env > options > config 优先级链: - env var 与 options.directory 同时设置时 env var 胜出 - env var 与 config.openspec.directory 同时设置时 env var 胜出 --- test/plugin.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index d468231..4ed501b 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -286,4 +286,42 @@ describe("OpencodeSpec custom directory option", () => { delete process.env.OPENSPEC_DIR } }) + + it("环境变量优先级高于 options.directory", async () => { + const projectDir = await makeTempDir() + await mkdir(path.join(projectDir, "assets", "skills", "openspec-propose"), { recursive: true }) + await mkdir(path.join(projectDir, "assets", "commands"), { recursive: true }) + await writeFile(path.join(projectDir, "package.json"), JSON.stringify({ version: "0.1.0" })) + + process.env.OPENSPEC_DIR = "env-wins" + try { + const plugin = await OpencodeSpec( + { client: {} as never, directory: projectDir, worktree: projectDir } as never, + { directory: "options-dir" }, + ) + expect(getOpenspecDir()).toBe("env-wins") + expect(plugin).toBeDefined() + } finally { + delete process.env.OPENSPEC_DIR + } + }) + + it("环境变量优先级高于 config.openspec.directory", async () => { + const projectDir = await makeTempDir() + await mkdir(path.join(projectDir, "assets", "skills", "openspec-propose"), { recursive: true }) + await mkdir(path.join(projectDir, "assets", "commands"), { recursive: true }) + await writeFile(path.join(projectDir, "package.json"), JSON.stringify({ version: "0.1.0" })) + + process.env.OPENSPEC_DIR = "env-wins" + try { + const plugin = await OpencodeSpec( + { client: {} as never, directory: projectDir, worktree: projectDir } as never, + ) + const config = { openspec: { directory: "config-dir" } } as Record + await plugin.config?.(config) + expect(getOpenspecDir()).toBe("env-wins") + } finally { + delete process.env.OPENSPEC_DIR + } + }) }) From b184e9f516627f6552e108c792e9121a76de5486 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:52:55 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(paths):=20design.ts=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20designPath()=20=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将硬编码 path.join(targetDir, 'design.md') 替换为 designPath(input.projectDir, slug) 移除不再需要的 node:path import --- src/core/artifact/design.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/artifact/design.ts b/src/core/artifact/design.ts index f2117e6..cce8a8b 100644 --- a/src/core/artifact/design.ts +++ b/src/core/artifact/design.ts @@ -1,7 +1,5 @@ -import path from "node:path" - import { changeDir, getTemplate, pathExists, renderTemplate, slugify, writeText } from "../../util/common.js" -import { toRelativePath } from "../../util/paths.js" +import { designPath, toRelativePath } from "../../util/paths.js" export interface UpdateDesignInput { projectDir: string @@ -17,7 +15,7 @@ export interface UpdateDesignInput { export async function updateDesign(input: UpdateDesignInput) { const slug = slugify(input.name) const targetDir = changeDir(input.projectDir, slug) - const filePath = path.join(targetDir, "design.md") + const filePath = designPath(input.projectDir, slug) if (!(await pathExists(targetDir))) { throw new Error(`未找到变更 ${slug}`) From dd14067d8126994fc7a41155974c16d1032879ff Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:53:40 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(paths):=20tasks.ts=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=20tasksPath()=20=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将硬编码 path.join(targetDir, 'tasks.md') 替换为 tasksPath(input.projectDir, slug) 移除不再需要的 node:path import --- src/core/artifact/tasks.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/artifact/tasks.ts b/src/core/artifact/tasks.ts index 800216d..cc19fd4 100644 --- a/src/core/artifact/tasks.ts +++ b/src/core/artifact/tasks.ts @@ -1,7 +1,5 @@ -import path from "node:path" - import { changeDir, getTemplate, pathExists, renderTemplate, slugify, validateTasksMarkdown, writeText } from "../../util/common.js" -import { toRelativePath } from "../../util/paths.js" +import { tasksPath, toRelativePath } from "../../util/paths.js" export interface UpdateTasksInput { projectDir: string @@ -18,7 +16,7 @@ export interface UpdateTasksInput { export async function updateTasks(input: UpdateTasksInput) { const slug = slugify(input.name) const targetDir = changeDir(input.projectDir, slug) - const filePath = path.join(targetDir, "tasks.md") + const filePath = tasksPath(input.projectDir, slug) if (!(await pathExists(targetDir))) { throw new Error(`未找到变更 ${slug}`) From 10fe9ad16cb49478f680436ed3acff91ea33f8af Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:02:59 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(env):=20=E4=BF=AE=E5=A4=8D=20process.en?= =?UTF-8?q?v.OPENSPEC=5FDIR=20=E8=A2=AB=20options.directory=20=E6=97=A0?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E8=A6=86=E7=9B=96=E7=9A=84=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: 将 process.env.OPENSPEC_DIR = dir 移入 env var 守卫内, 确保环境变量设置后不会被 options/config 覆盖。 同时补充 process.env.OPENSPEC_DIR 断言测试。 --- src/plugin/server.ts | 8 +++++--- test/plugin.test.ts | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/plugin/server.ts b/src/plugin/server.ts index 223fed9..89d3d70 100644 --- a/src/plugin/server.ts +++ b/src/plugin/server.ts @@ -27,8 +27,8 @@ export function createOpencodeSpec(packageRoot: string): Plugin { const dir = options.directory.trim() if (!process.env.OPENSPEC_DIR?.trim()) { setOpenspecDir(dir) + process.env.OPENSPEC_DIR = dir } - process.env.OPENSPEC_DIR = dir } const sourceSkillsDir = path.join(packageRoot, "assets", "skills") @@ -46,8 +46,10 @@ export function createOpencodeSpec(packageRoot: string): Plugin { const openspecConfig = config.openspec as Record | undefined if (openspecConfig?.directory && typeof openspecConfig.directory === "string" && openspecConfig.directory.trim()) { const dir = openspecConfig.directory.trim() - setOpenspecDir(dir) - process.env.OPENSPEC_DIR = dir + if (!process.env.OPENSPEC_DIR?.trim()) { + setOpenspecDir(dir) + process.env.OPENSPEC_DIR = dir + } } } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 4ed501b..608c4cb 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -300,6 +300,7 @@ describe("OpencodeSpec custom directory option", () => { { directory: "options-dir" }, ) expect(getOpenspecDir()).toBe("env-wins") + expect(process.env.OPENSPEC_DIR).toBe("env-wins") expect(plugin).toBeDefined() } finally { delete process.env.OPENSPEC_DIR @@ -320,6 +321,7 @@ describe("OpencodeSpec custom directory option", () => { const config = { openspec: { directory: "config-dir" } } as Record await plugin.config?.(config) expect(getOpenspecDir()).toBe("env-wins") + expect(process.env.OPENSPEC_DIR).toBe("env-wins") } finally { delete process.env.OPENSPEC_DIR }