Skip to content
40 changes: 40 additions & 0 deletions docs/2026-06-13-issue-2-fix-env-var-paths-brief.md
Original file line number Diff line number Diff line change
@@ -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` 中的任何变更
113 changes: 113 additions & 0 deletions docs/diagnosis-issue-2.md
Original file line number Diff line number Diff line change
@@ -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 测试失败为已有问题,非本次修复引入)
8 changes: 3 additions & 5 deletions src/core/design.ts → src/core/artifact/design.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
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 { designPath, toRelativePath } from "../../util/paths.js"

export interface UpdateDesignInput {
projectDir: string
Expand All @@ -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}`)
Expand Down
14 changes: 7 additions & 7 deletions src/core/instructions.ts → src/core/artifact/instructions.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/core/proposal.ts → src/core/artifact/proposal.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/core/specs.ts → src/core/artifact/specs.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 3 additions & 5 deletions src/core/tasks.ts → src/core/artifact/tasks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
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 { tasksPath, toRelativePath } from "../../util/paths.js"

export interface UpdateTasksInput {
projectDir: string
Expand All @@ -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}`)
Expand Down
8 changes: 4 additions & 4 deletions src/core/change.ts → src/core/change/change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions src/core/list.ts → src/core/change/list.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions src/core/new.ts → src/core/change/new.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 5 additions & 5 deletions src/core/status.ts → src/core/change/status.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
44 changes: 22 additions & 22 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -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"
File renamed without changes.
2 changes: 1 addition & 1 deletion src/core/schemas.ts → src/core/model/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { loadProjectConfig } from "./config.js"
import { loadProjectConfig } from "../project/config.js"
import { getSchema } from "./schema.js"

/**
Expand Down
File renamed without changes.
Loading
Loading