@zhushanwen/pi-plan 0.4.9 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -13
- package/package.json +15 -5
- package/skills/plan-ext-config/SKILL.md +86 -0
- package/src/__tests__/command.test.ts +290 -10
- package/src/__tests__/compact-criteria-array.test.ts +12 -8
- package/src/__tests__/compact-handler.test.ts +84 -35
- package/src/__tests__/enter.test.ts +172 -0
- package/src/__tests__/exec-skills.test.ts +397 -0
- package/src/__tests__/i18n.test.ts +57 -0
- package/src/__tests__/index.test.ts +228 -0
- package/src/__tests__/prompts.test.ts +74 -0
- package/src/__tests__/review.test.ts +489 -0
- package/src/__tests__/skills.test.ts +258 -0
- package/src/__tests__/state.test.ts +395 -16
- package/src/__tests__/templates.test.ts +203 -15
- package/src/__tests__/tool.test.ts +389 -73
- package/src/command.ts +220 -62
- package/src/compact.ts +43 -27
- package/src/enter.ts +133 -0
- package/src/exec-skills.ts +407 -0
- package/src/i18n.ts +70 -0
- package/src/index.ts +106 -5
- package/src/prompts.ts +151 -0
- package/src/state.ts +230 -8
- package/src/templates.ts +125 -7
- package/src/tool.ts +692 -110
- package/src/widget.ts +6 -1
package/README.md
CHANGED
|
@@ -1,35 +1,63 @@
|
|
|
1
1
|
# @zhushanwen/pi-plan
|
|
2
2
|
|
|
3
|
-
轻量级规划模式 pi extension:`/plan [描述]`
|
|
3
|
+
轻量级规划模式 pi extension:`/plan [描述]` 触发,只读探索并产出结构化计划文档。与 coding-workflow 的区别:无 gate / review / retrospect,只负责「想清楚再动手」的计划产出(ADR pi-ext-021 prompt-only readonly / pi-ext-022 session-manager state)。
|
|
4
4
|
|
|
5
5
|
## 用法
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
/plan 修复项目中所有失败的测试
|
|
9
|
-
/plan
|
|
10
|
-
/plan
|
|
8
|
+
/plan 修复项目中所有失败的测试 # 进入 plan mode(模板流程)
|
|
9
|
+
/plan 重构 auth 模块 --skills tech-design,dev-flow # 进入 plan mode(挂载技能流程)
|
|
10
|
+
/plan 复盘事故 --template ~/docs/retro.md # 进入 plan mode(直传模板文件)
|
|
11
|
+
/plan status # 查看状态、技能与产物清单
|
|
12
|
+
/plan abort # 取消活跃的 plan mode
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
`--skills` 接受逗号分隔的技能名(按逗号原样切分 + 去首尾空格,含中文/内部空格的技能名不丢字符)。技能名经 `pi.getCommands()` 的 `source === "skill"` 枚举校验,不存在则不进入计划模式并回复可用技能清单。未指定 `--skills` 时回落模板流程。
|
|
14
16
|
|
|
15
|
-
`
|
|
17
|
+
`--template <path>` 直传任意外部 md 文件作模板(与 `--skills` 互斥,同给 fail-fast):`~` 前缀展开、含空格路径无需引号(flag 后整段即路径);文件不存在 / 非 .md 分别报错(不进入计划态)。进入后提示词内嵌该文件全文且不注入模板清单段,无需 select-template。
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
## 计划态生命周期
|
|
20
|
+
|
|
21
|
+
进入后 agent 限只读工具集(read/bash/grep/find/ls/plan),按注入提示词产出文档:
|
|
22
|
+
|
|
23
|
+
- **技能流程**(挂载 `--skills`):按技能 SKILL.md 流程产出文档(AI 自行 read 技能文件)。
|
|
24
|
+
- **模板流程**(未挂载):从三源发现的模板清单中选型后撰写 plan.md(见下节)。
|
|
25
|
+
|
|
26
|
+
文档全部就绪后调 `submit-review` 提交审阅;taiji rpc 宿主(`TAIJI_AGENT_EXT_LOG=1` 且 rpc 模式)挂 `PLAN_REVIEW_MARKER` select 弹 GUI 审批(approve 确认执行 / revise 提交评论修订,取消走 dismiss 非批准),独立 pi / 非 rpc 形态返回文本软门(对话中反馈评论,满意后调 `complete`)。
|
|
20
27
|
|
|
21
28
|
## plan 工具
|
|
22
29
|
|
|
23
|
-
模型经 `plan` tool
|
|
30
|
+
模型经 `plan` tool 驱动状态流转,actions 六项:`enter` / `select-template` / `register-doc` / `submit-review` / `complete` / `abort`。
|
|
31
|
+
|
|
32
|
+
- `register-doc`:登记一份产物文档(同 fileName 重登 = version+1 覆盖),供 GUI 产物 tab 展示与内容刷新。
|
|
33
|
+
- `submit-review`:全部文档就绪后请求审阅。docs 为空或计划态已退出时返回错误提示(E6 双守卫)。
|
|
34
|
+
- `complete`:先弹执行方式选择(GUI 宿主经统一表单协议单 choice 问题)。选项 = 自动检测的 plan-exec skill 档(≤2 个,label `Execute via skill: <name>`)+ Execute 档(goal 跟踪已整合:可用时先建 goal 跟踪,再按复杂度派发 subagent 并行、小步/紧耦合本会话直执)+ 暂不执行(Not now,留在 plan mode)。headless 无 UI 默认 execute 不弹选择;通道失败(channel-error / non-json)与用户取消同折叠为 complete-cancelled result,留在 plan mode 不炸 turn。
|
|
35
|
+
- `abort`:直接退出。两者退出后恢复工具集;状态落 isActive=false + docs 保留(产物跨重开留存)。
|
|
36
|
+
|
|
37
|
+
## 执行方式与 plan-exec skill
|
|
38
|
+
|
|
39
|
+
`complete` 选项中的 skill 档来自运行时自动检测:skill 作者在 SKILL.md frontmatter 标记 `plan-exec: true`,该 skill 即被提议为执行方式(label `Execute via skill: <name>`)。检测对过 description 必填门的 skill 二次读 frontmatter 判定;`disable-model-invocation` 与 plan-exec 并存不过滤。
|
|
40
|
+
|
|
41
|
+
四根扫描(单根枚举复用 pi 公开导出的 `loadSkillsFromDir`,跨根组装同构 pi 本体加载序):project `.pi/skills` → 祖先链 `.agents/skills`(近→远,git root 级含)→ `<agentDir>/skills` → `~/.agents/skills`;同名 first-writer-wins + realPath 去重;untrusted 项目跳过前两族。complete 时现扫无缓存(技能热装即见)。
|
|
42
|
+
|
|
43
|
+
检测失败降级为空集,绝不炸 complete 交互闭环:每根 existsSync 守卫 + 整根 try/catch(EACCES 等 fs 错 → 跳过该根 + warn),单 skill frontmatter 读/解析失败跳过该项。
|
|
24
44
|
|
|
25
45
|
## Plan File
|
|
26
46
|
|
|
27
|
-
|
|
47
|
+
模板流程的产出物,存储在 `.taiji-harness/{slug}/plan.md`(slug 截断 30 字符)。含 YAML frontmatter 与模板章节。进入 plan mode 时自动扫描既有 plan 文件供续写。技能流程的产物文档同写在该目录下,经 `register-doc` 登记。
|
|
28
48
|
|
|
29
49
|
## 模板
|
|
30
50
|
|
|
31
|
-
|
|
51
|
+
三源发现(同名 last-writer-wins,项目 > 用户 > 内置):
|
|
52
|
+
|
|
53
|
+
1. **内置**(包内 `templates/`):`feature-plan` / `bugfix-plan` / `refactor-plan` / `implementation-plan` / `research-plan`
|
|
54
|
+
2. **用户级** `~/.agents/plans/*.md`:个人模板投放点
|
|
55
|
+
3. **项目级** `<project>/.agents/plans/*.md`:项目/团队模板投放点(项目级覆盖用户级覆盖内置)
|
|
56
|
+
|
|
57
|
+
进入计划态时全量清单以 `<available-plans>` 段随提示词一次性注入(name + location),模型自选后调 `select-template`——返回的 content 携带胜者文件全文(章节骨架直达,无需再 read);错名报错自带可用清单,模型当场自愈。`--template <path>` 直传时跳过选型(见用法节)。
|
|
58
|
+
|
|
59
|
+
**goal 桥结构建议**:自定义模板(含直传文件)建议包含 `## Implementation Steps` 编号步骤节——`complete` 选 Execute 档且 goal 跟踪可用时,步骤提取(`extractPlanSteps` 正则)硬依赖该节;无该节会退化到 fallback(扫全部编号项,可能收进其他节噪音)或提取失败(no-steps 启动失败,有恢复文案)。
|
|
32
60
|
|
|
33
61
|
## 依赖
|
|
34
62
|
|
|
35
|
-
peer
|
|
63
|
+
依赖:`@zhushanwen/extension-protocol`(PLAN_REVIEW_MARKER + PlanReviewRequest/Response 契约 + ui-form(`uiFormInteract` / `FormQuestion`,统一提问表单协议),dependencies);peer 依赖:`@zhushanwen/pi-goal`(plan 完成后衔接 goal 驱动执行)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-plan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Lightweight plan mode for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"pi": {
|
|
11
11
|
"extensions": [
|
|
12
12
|
"./index.ts"
|
|
13
|
+
],
|
|
14
|
+
"skills": [
|
|
15
|
+
"./skills"
|
|
13
16
|
]
|
|
14
17
|
},
|
|
15
18
|
"keywords": [
|
|
@@ -18,12 +21,16 @@
|
|
|
18
21
|
],
|
|
19
22
|
"license": "MIT",
|
|
20
23
|
"peerDependencies": {
|
|
21
|
-
"@earendil-works/pi-coding-agent": "
|
|
24
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
25
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
22
26
|
"typebox": "*",
|
|
23
27
|
"@earendil-works/pi-ai": "^0.84.4",
|
|
24
|
-
"@zhushanwen/pi-goal": "0.14.
|
|
28
|
+
"@zhushanwen/pi-goal": "0.14.6"
|
|
25
29
|
},
|
|
26
30
|
"peerDependenciesMeta": {
|
|
31
|
+
"@earendil-works/pi-tui": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
27
34
|
"@earendil-works/pi-ai": {
|
|
28
35
|
"optional": true
|
|
29
36
|
},
|
|
@@ -32,18 +39,21 @@
|
|
|
32
39
|
}
|
|
33
40
|
},
|
|
34
41
|
"devDependencies": {
|
|
42
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
35
43
|
"@vitest/coverage-v8": "^4.1.9",
|
|
36
44
|
"vitest": "^4.1.8"
|
|
37
45
|
},
|
|
38
46
|
"files": [
|
|
39
47
|
"index.ts",
|
|
40
48
|
"src/",
|
|
49
|
+
"skills/",
|
|
41
50
|
"templates/"
|
|
42
51
|
],
|
|
43
52
|
"dependencies": {
|
|
44
|
-
"@zhushanwen/extension-protocol": "0.
|
|
53
|
+
"@zhushanwen/extension-protocol": "0.13.0",
|
|
45
54
|
"@zhushanwen/pi-ext-guards": "0.4.1",
|
|
46
|
-
"@zhushanwen/pi-extension-logger": "0.6.1"
|
|
55
|
+
"@zhushanwen/pi-extension-logger": "0.6.1",
|
|
56
|
+
"@zhushanwen/pi-llm-shared": "0.9.0"
|
|
47
57
|
},
|
|
48
58
|
"scripts": {
|
|
49
59
|
"typecheck": "npx tsc --noEmit",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan-ext-config
|
|
3
|
+
description: "使用或排查 @zhushanwen/pi-plan(计划模式 plan mode)时加载。说明 /plan 命令用法(--skills / --template / status / abort)、plan 工具六 action(enter / select-template / register-doc / submit-review / complete / abort)、审阅两键裁决(approve / revise)、执行方式选项集(plan-exec skill 检测 + Execute + 暂不执行)、数据存储模型(session JSONL 的 plan-state entry,无独立配置文件)、reviewState 崩溃恢复语义。触发词:plan mode、计划模式、/plan、plan 工具、submit-review、register-doc、plan-state、计划文档登记、审阅挂起、计划模式排查、plan-ext-config。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# plan 使用与存储指南
|
|
7
|
+
|
|
8
|
+
> @zhushanwen/pi-plan:轻量计划模式扩展。进入后 agent 限只读工具集(read/bash/grep/find/ls/plan),只读探索并产出计划文档,用户审阅批准后才进入实施——「想清楚再动手」。
|
|
9
|
+
|
|
10
|
+
**重要前提**:plan **没有独立的配置文件**。计划态以 append-only 方式存储在 session JSONL 的 `plan-state` custom entry 中(见下文「数据存储位置」)。排查「plan 状态存哪 / 重启后为什么还在计划态 / 审阅挂起怎么恢复」都必须基于此模型理解,不要去找独立的 plan 配置文件。
|
|
11
|
+
|
|
12
|
+
## 如何进入/退出计划模式
|
|
13
|
+
|
|
14
|
+
### 1. `/plan` slash 命令(用户输入)
|
|
15
|
+
|
|
16
|
+
| 用法 | 作用 |
|
|
17
|
+
|------|------|
|
|
18
|
+
| `/plan <需求>` | 进入计划模式(模板流程) |
|
|
19
|
+
| `/plan <需求> --skills a,b` | 进入计划模式(挂载技能流程,技能名经 pi 已加载技能枚举校验,未知名不进入并回复可用清单) |
|
|
20
|
+
| `/plan <需求> --template <path>` | 进入计划模式(直传任意外部 md 作模板;`~` 前缀展开;与 `--skills` 互斥 fail-fast) |
|
|
21
|
+
| `/plan status` | 查看状态、技能与产物清单 |
|
|
22
|
+
| `/plan abort` | 取消活跃的 plan mode |
|
|
23
|
+
|
|
24
|
+
无参 `/plan`:活跃时显示 status;非活跃时扫描 `.tmp/plans/` 下既有 plan 文件,找到则让用户选择继续/实施/新建。
|
|
25
|
+
|
|
26
|
+
### 2. `plan` tool(AI 调用,六 action)
|
|
27
|
+
|
|
28
|
+
- **`enter`**:agent 自助进入(无需用户确认——plan mode 只读无害,进入事实经 GUI PlanModeBar 显形,用户随时可退出)。参数 `requirement` + 可选 `skills`。
|
|
29
|
+
- **`select-template`**:模板流程选型(`--template` 直传后调用会报错)。返回 content 携带胜者模板全文;错名报错自带可用清单自愈。
|
|
30
|
+
- **`register-doc`**:登记一份产物文档(参数 `fileName` 必须是 plan 目录内纯文件名,路径分隔符/`..` 被拒;同 fileName 重登 = version+1 原位覆盖,UI tab 顺序不跳动)。absPath 由扩展从 plan 目录推导,不信任 LLM 申报路径。
|
|
31
|
+
- **`submit-review`**:全部文档就绪后请求审阅(docs 为空或已退出计划态返回错误提示,不挂交互)。
|
|
32
|
+
- **`complete`**:用户 approve 后退出计划态,先弹执行方式选择(见下节)。
|
|
33
|
+
- **`abort`**:直接退出。
|
|
34
|
+
|
|
35
|
+
退出(complete/abort)恢复完整工具集;`plan-state` 落 isActive=false,docs 清单保留(产物文档跨重开可回看,至下次 /plan 同 slug 覆写)。
|
|
36
|
+
|
|
37
|
+
## 审阅闭环(submit-review → 两键裁决)
|
|
38
|
+
|
|
39
|
+
- **taiji rpc 宿主**(`TAIJI_AGENT_EXT_LOG=1` 且 `mode==='rpc'`):挂 `PLAN_REVIEW_MARKER` select 弹 GUI 审批条,用户两键裁决——**approve**(确认执行,自动走 complete 流程)/ **revise**(提交 `{quote, comment}[]` 评论,注入对话要求修订)。取消(dismiss)非批准:result 明确禁止实施、停止审批循环等用户指示。
|
|
40
|
+
- **独立 pi / 非 rpc 形态**:文本软门——result 指示 agent 请用户直接在对话中反馈;用户满意确认后 agent 调 `complete`。
|
|
41
|
+
- **修订闭环**:revise 后逐条评论 rewrite 文件 + 重新 `register-doc`(version+1),全部处理完再调 `submit-review` 重挂,循环直至 approve。
|
|
42
|
+
- **重提交无变化检测**:上次 submit-review 后无任何 register-doc(docs 指纹相同)再次 submit-review 时,result 末行追加确定性警告,提示必须先 rewrite + re-register。
|
|
43
|
+
|
|
44
|
+
## 执行方式选项集(complete 时)
|
|
45
|
+
|
|
46
|
+
选项 = **≤2 个检测到的 plan-exec skill 档**(label `Execute via skill: <name>`)+ **Execute 档** + **暂不执行(Not now)**:
|
|
47
|
+
|
|
48
|
+
- **plan-exec skill 档**:skill 作者在 SKILL.md frontmatter 标记 `plan-exec: true` 即被提议。四根扫描(project `.pi/skills` → 祖先链 `.agents/skills` → `<agentDir>/skills` → `~/.agents/skills`,同构 pi 本体加载序,untrusted 项目跳过前两族),root 序同名 first-writer-wins,取前 2 个。complete 时现扫无缓存(技能热装即见);检测失败降级为空集,绝不阻断交互。
|
|
49
|
+
- **Execute 档**:goal 跟踪已整合——goal 扩展可用时先建 goal 跟踪(`/goal`),再按复杂度执行:独立可并行任务派发 subagent、小步/紧耦合在本会话直执;goal 不可用/启动失败降级为直接执行(notify 提示 + result 带恢复动作)。
|
|
50
|
+
- **暂不执行**:留在 plan mode,不派发实施。
|
|
51
|
+
|
|
52
|
+
headless(json/print 无 UI)默认 execute 不弹选择;通道失败(channel-error / non-json)与用户取消同折叠为 complete-cancelled result,留在 plan mode 不炸 turn。
|
|
53
|
+
|
|
54
|
+
**goal 桥结构建议**:计划文档建议包含 `## Implementation Steps` 编号步骤节——Execute 档的 goal 步骤提取硬依赖该节;缺失会退化 fallback(扫全部编号项)或提取失败(no-steps,有恢复文案)。
|
|
55
|
+
|
|
56
|
+
## 数据存储位置(排查必读)
|
|
57
|
+
|
|
58
|
+
**当前版本采用 session JSONL 的 append-only event sourcing,没有独立数据文件。**
|
|
59
|
+
|
|
60
|
+
- 每次状态变更调 `pi.appendEntry('plan-state', {...})`,customType 固定为 `plan-state`;重建时取 session 内**最后一条** plan-state entry 为当前态(逐字段白名单降级读,旧版 entry 缺字段归一为空/无值)。
|
|
61
|
+
- 字段:`isActive` / `planFilePath` / `requirement` / `templateName` / `templateProvidedPath`(--template 直传标记)/ `skills`(挂载技能名)/ `docs`(产物清单 `PlanDocMeta[]`:fileName + absPath + sourceSkill + version)/ `reviewState` / `lastSubmitReviewDocsFingerprint` / `reviewStateSource`。
|
|
62
|
+
- **产物文档本体**写在 `<cwd>/.tmp/plans/<slug>/`(slug 截断 30 字符)——文档是普通文件,agent 经 bash 写入;`register-doc` 只登记元数据。未产任何文档即退出会清理空 slug 目录(非空一律保留)。
|
|
63
|
+
- **模板三源发现**(同名 last-writer-wins,项目 > 用户 > 内置):内置(包内 `templates/`,5 个)+ 用户级 `~/.agents/plans/*.md` + 项目级 `<project>/.agents/plans/*.md`。进入时清单以 `<available-plans>` 段一次性注入。
|
|
64
|
+
|
|
65
|
+
## reviewState 崩溃恢复(E3)
|
|
66
|
+
|
|
67
|
+
`reviewState` 两值:`awaiting`(submit-review 挂起前落盘)| `revising`(revise 消费后落盘)| 无值 = 进行中。挂起的交互(审批 select / 执行方式 form)随 pi 进程消亡,但持久态经 entry 存活:
|
|
68
|
+
|
|
69
|
+
- session 重启(session_start)时若 `reviewState='awaiting'`:steer 指示 agent 重调 `submit-review` 重挂审批,并落 `reviewStateSource='resubmit'`(GUI 据此渲染「会话已重启,尚未重新提交」降级态;submit-review 重挂起时清空)。
|
|
70
|
+
- `reviewState='revising'`:steer 指示继续按已注入的评论修订文档并重新提交。
|
|
71
|
+
- 「approve 后执行方式选择窗口中断」不自动恢复(与探索期在持久态上不可区分,防误报),由用户重发消息自然恢复。
|
|
72
|
+
|
|
73
|
+
## 排障
|
|
74
|
+
|
|
75
|
+
- **submit-review 返回「taiji host does not understand the plan review marker」**:taiji 宿主版本过旧(select 回显 payload 的确定性识别)。不要重挂(会同样回显循环),指引升级 taiji 或固定 plan 扩展版本。
|
|
76
|
+
- **submit-review 返回 cancelled**:用户 dismiss 或 plan mode 退出——非批准,禁止实施,等用户指示。
|
|
77
|
+
- **重提交总带「no documents changed」警告**:必须先 rewrite 文件 + `register-doc`(version+1)再 submit-review,警告按 docs 指纹逐次检测。
|
|
78
|
+
- **plan-exec skill 没出现在执行选项里**:确认 frontmatter 有 `plan-exec: true`(严格布尔 true,字符串 "true" 不算);确认 skill 过了 pi 的 description 必填门且未被 settings overrides(`-`/`!`)禁用;untrusted 项目不扫 `.pi/skills` 与祖先链两族。
|
|
79
|
+
- **goal 跟踪未启动(no-steps / plan-unreadable 等)**:result 文本带逐原因恢复动作(如补 `## Implementation Steps` 节后重调 complete);goal 扩展未装时属正常降级(直接执行)。
|
|
80
|
+
- **调试**:`TAIJI_AGENT_DEBUG=1` 后看 `~/.pi/agent/logs/` 下 `[pi-plan]` 前缀日志。
|
|
81
|
+
|
|
82
|
+
## 备注
|
|
83
|
+
|
|
84
|
+
- **宿主信号**:taiji runtime 对托管 pi 恒注入 `TAIJI_AGENT_EXT_LOG=1`(引导文案注入 + marker 交互判据之一);marker 交互另需 `mode==='rpc'`,env 泄漏到独立 pi TUI 时回落文本软门(不挂乱码 select)。
|
|
85
|
+
- **计划态工具白名单**:read / bash / grep / find / ls / plan——bash 在白名单内,文件写约束来自注入的计划模式提示词(产物只写 plan 目录)。
|
|
86
|
+
- **无配置 schema 可编辑**:plan 的所有状态都由运行时命令/工具产生,没有可手动编辑的配置文件。
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { beforeEach,describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// --template 直传注入的全文 fixture(vi.hoisted:vi.mock 工厂引用需先于模块体初始化)
|
|
4
|
+
const TEMPLATE_FILE_FIXTURE = vi.hoisted(() => "# retro skeleton\n\n## Implementation Steps\n- step one\n");
|
|
2
5
|
|
|
3
6
|
// Mock dependencies before importing
|
|
4
7
|
vi.mock("node:fs", () => ({
|
|
@@ -6,6 +9,8 @@ vi.mock("node:fs", () => ({
|
|
|
6
9
|
readdirSync: vi.fn(() => []),
|
|
7
10
|
statSync: vi.fn(),
|
|
8
11
|
existsSync: vi.fn(() => false),
|
|
12
|
+
// --template 直传的全文内嵌读取(v2b:prompts 分支消费)
|
|
13
|
+
readFileSync: vi.fn(() => TEMPLATE_FILE_FIXTURE),
|
|
9
14
|
}));
|
|
10
15
|
|
|
11
16
|
vi.mock("../widget.js", () => ({
|
|
@@ -13,15 +18,19 @@ vi.mock("../widget.js", () => ({
|
|
|
13
18
|
}));
|
|
14
19
|
|
|
15
20
|
import * as fs from "node:fs";
|
|
21
|
+
import * as os from "node:os";
|
|
22
|
+
import * as path from "node:path";
|
|
16
23
|
|
|
17
24
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
25
|
|
|
19
|
-
import { registerPlanCommand } from "../command.js";
|
|
26
|
+
import { parsePlanArgs, registerPlanCommand, resolveTemplateFile } from "../command.js";
|
|
27
|
+
import { MAX_PLAN_REQUIREMENT_LENGTH, PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
|
|
20
28
|
|
|
21
29
|
const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
|
|
22
30
|
|
|
23
31
|
function createMocks() {
|
|
24
32
|
let capturedHandler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
33
|
+
const controllers = new Map<string, AbortController>();
|
|
25
34
|
|
|
26
35
|
const pi = {
|
|
27
36
|
registerCommand: vi.fn((_name: string, def: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) => {
|
|
@@ -29,7 +38,8 @@ function createMocks() {
|
|
|
29
38
|
}),
|
|
30
39
|
appendEntry: vi.fn(),
|
|
31
40
|
setActiveTools: vi.fn(),
|
|
32
|
-
|
|
41
|
+
sendMessage: vi.fn(),
|
|
42
|
+
getCommands: vi.fn(() => []),
|
|
33
43
|
getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
|
|
34
44
|
} as unknown as ExtensionAPI;
|
|
35
45
|
|
|
@@ -50,22 +60,30 @@ function createMocks() {
|
|
|
50
60
|
return {
|
|
51
61
|
pi,
|
|
52
62
|
ctx,
|
|
63
|
+
controllers,
|
|
53
64
|
getHandler: () => capturedHandler!,
|
|
54
65
|
};
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
/** custom message 形态断言 helper(A2 三要素:customType/content/display + triggerTurn options;P1-P4 无 deliverAs) */
|
|
69
|
+
function planContextMessage(content: unknown) {
|
|
70
|
+
return [{ customType: PLAN_CONTEXT_CUSTOM_TYPE, content, display: false }, { triggerTurn: true }];
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
describe("registerPlanCommand", () => {
|
|
58
74
|
let pi: ExtensionAPI;
|
|
59
75
|
let ctx: ExtensionContext;
|
|
60
76
|
let handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
77
|
+
let controllers: Map<string, AbortController>;
|
|
61
78
|
|
|
62
79
|
beforeEach(() => {
|
|
63
80
|
vi.clearAllMocks();
|
|
64
81
|
const mocks = createMocks();
|
|
65
82
|
pi = mocks.pi;
|
|
66
83
|
ctx = mocks.ctx;
|
|
84
|
+
controllers = mocks.controllers;
|
|
67
85
|
const sessions = new Map();
|
|
68
|
-
registerPlanCommand(pi, sessions);
|
|
86
|
+
registerPlanCommand(pi, sessions, controllers);
|
|
69
87
|
handler = mocks.getHandler();
|
|
70
88
|
});
|
|
71
89
|
|
|
@@ -94,6 +112,31 @@ describe("registerPlanCommand", () => {
|
|
|
94
112
|
expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith("Plan mode aborted.", "info");
|
|
95
113
|
});
|
|
96
114
|
|
|
115
|
+
it("abort 联动(E10):controller.abort() 先于 resetPlanState 的 entry 落盘", async () => {
|
|
116
|
+
await handler("implement user auth", ctx);
|
|
117
|
+
vi.clearAllMocks();
|
|
118
|
+
|
|
119
|
+
// 模拟一个挂起 select 的 controller(submit-review / complete 执行方式两处共用注册表)
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const abortSpy = vi.spyOn(controller, "abort");
|
|
122
|
+
controllers.set("test-session", controller);
|
|
123
|
+
|
|
124
|
+
await handler("abort", ctx);
|
|
125
|
+
|
|
126
|
+
// 挂起 select 被 abort(→ resolve undefined → tool execute 已取消分支 → turn 结束 → settled → busy defer 恢复)
|
|
127
|
+
expect(controller.signal.aborted).toBe(true);
|
|
128
|
+
// 顺序断言:abort 必须先于 reset entry 落盘(反序 = 挂起 select 无人 resolve → session 卡死)
|
|
129
|
+
const resetCallIndex = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.findIndex(
|
|
130
|
+
(c) => (c[1] as { isActive?: boolean }).isActive === false,
|
|
131
|
+
);
|
|
132
|
+
expect(resetCallIndex).toBeGreaterThanOrEqual(0);
|
|
133
|
+
expect(abortSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
|
134
|
+
(pi.appendEntry as ReturnType<typeof vi.fn>).mock.invocationCallOrder[resetCallIndex],
|
|
135
|
+
);
|
|
136
|
+
// 注册表条目已清理
|
|
137
|
+
expect(controllers.has("test-session")).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
97
140
|
// --- status subcommand ---
|
|
98
141
|
|
|
99
142
|
it("status: notifies 'No active plan mode' when idle", async () => {
|
|
@@ -122,17 +165,20 @@ describe("registerPlanCommand", () => {
|
|
|
122
165
|
await handler("Implement User Auth", ctx);
|
|
123
166
|
|
|
124
167
|
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
125
|
-
"/tmp/test-project/.
|
|
168
|
+
"/tmp/test-project/.tmp/plans/implement-user-auth",
|
|
126
169
|
{ recursive: true },
|
|
127
170
|
);
|
|
128
171
|
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
129
|
-
expect(pi.
|
|
130
|
-
expect(pi.
|
|
172
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(...planContextMessage(expect.stringContaining("[PLAN MODE]")));
|
|
173
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(...planContextMessage(expect.stringContaining("Implement User Auth")));
|
|
131
174
|
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", {
|
|
132
175
|
isActive: true,
|
|
133
|
-
planFilePath: "/tmp/test-project/.
|
|
176
|
+
planFilePath: "/tmp/test-project/.tmp/plans/implement-user-auth/plan.md",
|
|
134
177
|
requirement: "Implement User Auth",
|
|
135
178
|
templateName: "",
|
|
179
|
+
skills: [],
|
|
180
|
+
docs: [],
|
|
181
|
+
reviewState: undefined,
|
|
136
182
|
});
|
|
137
183
|
});
|
|
138
184
|
|
|
@@ -140,7 +186,7 @@ describe("registerPlanCommand", () => {
|
|
|
140
186
|
await handler("Fix bug #123: 中文标题!", ctx);
|
|
141
187
|
|
|
142
188
|
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
143
|
-
expect.stringContaining("/.
|
|
189
|
+
expect.stringContaining("/.tmp/plans/fix-bug-123"),
|
|
144
190
|
{ recursive: true },
|
|
145
191
|
);
|
|
146
192
|
});
|
|
@@ -150,11 +196,50 @@ describe("registerPlanCommand", () => {
|
|
|
150
196
|
await handler("", ctx);
|
|
151
197
|
|
|
152
198
|
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
153
|
-
"/tmp/test-project/.
|
|
199
|
+
"/tmp/test-project/.tmp/plans/untitled",
|
|
154
200
|
{ recursive: true },
|
|
155
201
|
);
|
|
156
202
|
});
|
|
157
203
|
|
|
204
|
+
it("超长 requirement 进入:state/entry 按 64KB 封顶,提示词仍携带全文(MF-1-3)", async () => {
|
|
205
|
+
const huge = "x".repeat(MAX_PLAN_REQUIREMENT_LENGTH + 10000);
|
|
206
|
+
await handler(huge, ctx);
|
|
207
|
+
|
|
208
|
+
// entry(plan 帧数据源)封顶 + 省略标记
|
|
209
|
+
const entry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls[0][1] as { requirement: string };
|
|
210
|
+
expect(entry.requirement.length).toBeLessThan(huge.length);
|
|
211
|
+
expect(entry.requirement).toContain("characters omitted");
|
|
212
|
+
// 进入提示词携带全文直达模型(该通路由 message content 注册表登记兜底,不在此封顶)
|
|
213
|
+
const payload = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as { content: string };
|
|
214
|
+
expect(payload.content.length).toBeGreaterThan(MAX_PLAN_REQUIREMENT_LENGTH);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("进入新 plan 轮次清掉 entry 重建残留的指纹基线(重置清单与 resetPlanState 对齐,MF-1-4)", async () => {
|
|
218
|
+
// 旧版/异常 entry 形态:isActive=false 但指纹残留——重建进内存态后,新轮次进入
|
|
219
|
+
// 不得把它当「无变化」检测基线(否则首次 submit-review 误报 unchanged)
|
|
220
|
+
const staleCtx = {
|
|
221
|
+
...ctx,
|
|
222
|
+
sessionManager: {
|
|
223
|
+
getSessionId: () => "test-session",
|
|
224
|
+
getEntries: () => [
|
|
225
|
+
{
|
|
226
|
+
type: "custom",
|
|
227
|
+
customType: "plan-state",
|
|
228
|
+
data: { isActive: false, lastSubmitReviewDocsFingerprint: "design.md:1" },
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
} as unknown as ExtensionContext;
|
|
233
|
+
|
|
234
|
+
await handler("second round feature", staleCtx);
|
|
235
|
+
|
|
236
|
+
const entry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as {
|
|
237
|
+
lastSubmitReviewDocsFingerprint?: string;
|
|
238
|
+
};
|
|
239
|
+
expect(entry.lastSubmitReviewDocsFingerprint).toBeUndefined();
|
|
240
|
+
expect(entry.isActive).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
158
243
|
it("shows status when active with no args", async () => {
|
|
159
244
|
await handler("my feature", ctx);
|
|
160
245
|
vi.clearAllMocks();
|
|
@@ -170,4 +255,199 @@ describe("registerPlanCommand", () => {
|
|
|
170
255
|
"info",
|
|
171
256
|
);
|
|
172
257
|
});
|
|
258
|
+
|
|
259
|
+
// --- --template 直传(D5:校验 + 解析 + 最小进入)---
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 取第一条 sendMessage 的 content 文本(fail-fast 回复 / 进入提示词共用通道),
|
|
263
|
+
* 并断言 custom message 形态三要素(A2:customType/display + 无 deliverAs 时 triggerTurn)
|
|
264
|
+
*/
|
|
265
|
+
function sentMessage(): string {
|
|
266
|
+
const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
|
267
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
268
|
+
const [payload, options] = calls[0] as [{ content: string }, Record<string, unknown>];
|
|
269
|
+
expect(payload.customType).toBe(PLAN_CONTEXT_CUSTOM_TYPE);
|
|
270
|
+
expect(payload.display).toBe(false);
|
|
271
|
+
expect(options).toEqual({ triggerTurn: true });
|
|
272
|
+
return payload.content;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
describe("--template via /plan handler", () => {
|
|
276
|
+
afterEach(() => {
|
|
277
|
+
// 还原默认 existsSync=false,避免路径实现泄漏到后续用例
|
|
278
|
+
vi.mocked(fs.existsSync).mockImplementation(() => false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("mutual exclusion with --skills: fail-fast — no entry, no tool restriction, both usage forms in reply", async () => {
|
|
282
|
+
await handler("复盘 --template tpl.md --skills a", ctx);
|
|
283
|
+
|
|
284
|
+
// 不进入计划模式(§3.1:不写 entry / 不限制工具 / 不注入计划提示词)
|
|
285
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
286
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
287
|
+
expect(pi.sendMessage).toHaveBeenCalledOnce();
|
|
288
|
+
const message = sentMessage();
|
|
289
|
+
expect(message).toContain("--template and --skills are mutually exclusive");
|
|
290
|
+
expect(message).toContain("/plan <requirement> --skills a,b");
|
|
291
|
+
expect(message).toContain("/plan <requirement> --template <path>");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("missing file: fail-fast reports resolved absolute path + usage sample", async () => {
|
|
295
|
+
// existsSync 默认 false(模块 mock 出厂实现)
|
|
296
|
+
await handler("复盘 --template tpl.md", ctx);
|
|
297
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
298
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
299
|
+
const message = sentMessage();
|
|
300
|
+
expect(message).toContain("Template file not found: /tmp/test-project/tpl.md");
|
|
301
|
+
expect(message).toContain("e.g. /plan <requirement> --template /path/to/template.md");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("existing non-markdown file: fail-fast reports 'Not a markdown file' (文案分家)", async () => {
|
|
305
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === "/tmp/test-project/notes.txt");
|
|
306
|
+
await handler("复盘 --template notes.txt", ctx);
|
|
307
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
308
|
+
const message = sentMessage();
|
|
309
|
+
expect(message).toContain("Not a markdown file: /tmp/test-project/notes.txt");
|
|
310
|
+
expect(message).not.toContain("not found");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("flag with no value: fail-fast (与 --skills 空值同款显式错误)", async () => {
|
|
314
|
+
await handler("复盘 --template", ctx);
|
|
315
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
316
|
+
const message = sentMessage();
|
|
317
|
+
expect(message).toContain("--template was given but no path followed it");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("valid template: enters plan mode — basename + 直传分支(全文内嵌 + 清单段抑制,v2b 终态)", async () => {
|
|
321
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === "/tmp/test-project/docs/retro-template.md");
|
|
322
|
+
await handler("retro meeting --template docs/retro-template.md", ctx);
|
|
323
|
+
|
|
324
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
325
|
+
"plan-state",
|
|
326
|
+
expect.objectContaining({
|
|
327
|
+
isActive: true,
|
|
328
|
+
requirement: "retro meeting",
|
|
329
|
+
templateName: "retro-template",
|
|
330
|
+
templateProvidedPath: "/tmp/test-project/docs/retro-template.md",
|
|
331
|
+
skills: [],
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
335
|
+
const prompt = sentMessage();
|
|
336
|
+
// 直传声明 + 全文内嵌(文件内容直达模型,不赌自发 read——D5)
|
|
337
|
+
expect(prompt).toContain("template was provided via --template");
|
|
338
|
+
expect(prompt).toContain("/tmp/test-project/docs/retro-template.md");
|
|
339
|
+
expect(prompt).toContain(TEMPLATE_FILE_FIXTURE);
|
|
340
|
+
expect(prompt).toContain("Do NOT call plan(action='select-template')");
|
|
341
|
+
// 清单段抑制:guide 行与「模板已指定」并存会诱导画蛇添足调 select-template
|
|
342
|
+
expect(prompt).not.toContain("<available-plans>");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("spaced path with ~ prefix: whole-segment value + homedir expansion reach validation and entry", async () => {
|
|
346
|
+
const spacedAbs = path.join(os.homedir(), "my plans", "retro template.md");
|
|
347
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === spacedAbs);
|
|
348
|
+
await handler("retro --template ~/my plans/retro template.md", ctx);
|
|
349
|
+
|
|
350
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
351
|
+
"plan-state",
|
|
352
|
+
expect.objectContaining({ isActive: true, templateName: "retro template", templateProvidedPath: spacedAbs }),
|
|
353
|
+
);
|
|
354
|
+
const prompt = sentMessage();
|
|
355
|
+
expect(prompt).toContain(spacedAbs);
|
|
356
|
+
expect(prompt).toContain(TEMPLATE_FILE_FIXTURE);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("parsePlanArgs (--template 整段取值语义, D5)", () => {
|
|
362
|
+
it("no flag: templatePath undefined", () => {
|
|
363
|
+
const parsed = parsePlanArgs("重构 auth 模块");
|
|
364
|
+
expect(parsed.templatePath).toBeUndefined();
|
|
365
|
+
expect(parsed.skills).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("value = whole text after the flag — spaces preserved without quoting (整段取值)", () => {
|
|
369
|
+
const parsed = parsePlanArgs("复盘事故 --template ~/docs/my retro template.md");
|
|
370
|
+
expect(parsed.requirement).toBe("复盘事故");
|
|
371
|
+
expect(parsed.templatePath).toBe("~/docs/my retro template.md");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("flag at the very start leaves empty requirement", () => {
|
|
375
|
+
const parsed = parsePlanArgs("--template tpl.md");
|
|
376
|
+
expect(parsed.requirement).toBe("");
|
|
377
|
+
expect(parsed.templatePath).toBe("tpl.md");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("'--templatex' is not treated as the flag (word boundary)", () => {
|
|
381
|
+
const parsed = parsePlanArgs("req --templatex is part of requirement");
|
|
382
|
+
expect(parsed.templatePath).toBeUndefined();
|
|
383
|
+
expect(parsed.requirement).toBe("req --templatex is part of requirement");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("both flags present (either order): both parsed — caller fail-fasts mutual exclusion", () => {
|
|
387
|
+
const templateFirst = parsePlanArgs("req --template a.md --skills b");
|
|
388
|
+
expect(templateFirst.templatePath).toBeDefined();
|
|
389
|
+
expect(templateFirst.skills).toBeDefined();
|
|
390
|
+
|
|
391
|
+
const skillsFirst = parsePlanArgs("req --skills b --template a.md");
|
|
392
|
+
expect(skillsFirst.templatePath).toBeDefined();
|
|
393
|
+
expect(skillsFirst.skills).toBeDefined();
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe("resolveTemplateFile (D5 校验与文案分家)", () => {
|
|
398
|
+
afterEach(() => {
|
|
399
|
+
vi.mocked(fs.existsSync).mockImplementation(() => false);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("empty value is an explicit error (flag given, nothing followed)", () => {
|
|
403
|
+
const res = resolveTemplateFile(" ", "/tmp/test-project");
|
|
404
|
+
expect(res.ok).toBe(false);
|
|
405
|
+
if (!res.ok) expect(res.problem).toContain("--template was given but no path followed it");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("~ prefix expands against os.homedir and missing file reports absolute path", () => {
|
|
409
|
+
const res = resolveTemplateFile("~/docs/retro.md", "/tmp/test-project");
|
|
410
|
+
expect(res.ok).toBe(false);
|
|
411
|
+
if (!res.ok) {
|
|
412
|
+
expect(res.problem).toContain(`Template file not found: ${path.join(os.homedir(), "docs/retro.md")}`);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("~name form is not expanded (no per-user home semantics): resolves against projectDir verbatim", () => {
|
|
417
|
+
// `~foo` 不是用户名展开场景(D5 最小语义:仅 `~` 本身与 `~/` 前缀展开)——
|
|
418
|
+
// 按字面路径相对 projectDir 解析,不得吞进 os.homedir()
|
|
419
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === path.resolve("/tmp/test-project", "~foo/plan.md"));
|
|
420
|
+
const res = resolveTemplateFile("~foo/plan.md", "/tmp/test-project");
|
|
421
|
+
expect(res.ok).toBe(true);
|
|
422
|
+
if (res.ok) {
|
|
423
|
+
expect(res.absPath).toBe(path.resolve("/tmp/test-project", "~foo/plan.md"));
|
|
424
|
+
expect(res.absPath).not.toContain(os.homedir());
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("relative path resolves against projectDir; not-found message carries usage sample", () => {
|
|
429
|
+
const res = resolveTemplateFile("tpl.md", "/tmp/test-project");
|
|
430
|
+
expect(res.ok).toBe(false);
|
|
431
|
+
if (!res.ok) {
|
|
432
|
+
expect(res.problem).toContain("Template file not found: /tmp/test-project/tpl.md");
|
|
433
|
+
expect(res.problem).toContain("e.g. /plan <requirement> --template /path/to/template.md");
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("existing non-markdown file reports 'Not a markdown file' (文案分家)", () => {
|
|
438
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === "/tmp/test-project/notes.txt");
|
|
439
|
+
const res = resolveTemplateFile("notes.txt", "/tmp/test-project");
|
|
440
|
+
expect(res.ok).toBe(false);
|
|
441
|
+
if (!res.ok) {
|
|
442
|
+
expect(res.problem).toContain("Not a markdown file: /tmp/test-project/notes.txt");
|
|
443
|
+
expect(res.problem).not.toContain("not found");
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("existing markdown file resolves ok with absolute path", () => {
|
|
448
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === "/tmp/test-project/docs/retro.md");
|
|
449
|
+
const res = resolveTemplateFile("docs/retro.md", "/tmp/test-project");
|
|
450
|
+
expect(res.ok).toBe(true);
|
|
451
|
+
if (res.ok) expect(res.absPath).toBe("/tmp/test-project/docs/retro.md");
|
|
452
|
+
});
|
|
173
453
|
});
|