@straussenl/opencode-craft 1.0.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/index.ts +422 -0
- package/package.json +18 -0
- package/state.ts +10 -0
- package/storage.ts +129 -0
- package/types.ts +36 -0
package/index.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
2
|
+
import {
|
|
3
|
+
readActivePlan,
|
|
4
|
+
writeActivePlan,
|
|
5
|
+
clearActivePlan,
|
|
6
|
+
readPlan,
|
|
7
|
+
writePlan,
|
|
8
|
+
listPlans,
|
|
9
|
+
generatePlanId,
|
|
10
|
+
getActivePlan,
|
|
11
|
+
requireNoActivePlan,
|
|
12
|
+
} from "./storage"
|
|
13
|
+
import { isValidTransition, isActiveStatus } from "./state"
|
|
14
|
+
import type { PlanMeta, PlanStatus, TaskStatus } from "./types"
|
|
15
|
+
|
|
16
|
+
const craftPlugin: Plugin = async (_ctx) => {
|
|
17
|
+
return {
|
|
18
|
+
config: (cfg: Record<string, unknown>) => {
|
|
19
|
+
const agents = (cfg.agent ?? {}) as Record<string, unknown>
|
|
20
|
+
cfg.agent = agents
|
|
21
|
+
|
|
22
|
+
agents.craft = {
|
|
23
|
+
description:
|
|
24
|
+
"Craft Planner — create structured, executable plans for your work. Use when you want to design a plan before starting implementation.",
|
|
25
|
+
mode: "primary",
|
|
26
|
+
color: "#EF4444",
|
|
27
|
+
permission: { edit: "deny", bash: "deny" },
|
|
28
|
+
prompt: `You are a **Craft Planner** in read-only mode — you cannot edit project files or run shell commands. Your tools are for creating and managing craft plan files only.
|
|
29
|
+
|
|
30
|
+
## Your Workflow
|
|
31
|
+
|
|
32
|
+
1. **Understand the request** — When the user describes what they want, first ask clarifying questions to understand the scope. Then summarize what the plan would cover and ask **"Shall I create this plan?"** Only call \`craft_create_plan\` after the user explicitly says yes. The plan starts in "forming" status.
|
|
33
|
+
|
|
34
|
+
2. **Ask clarifying questions** — Before finalizing, ask questions to understand scope, constraints, and priorities. The plan should be thorough enough for another agent to execute.
|
|
35
|
+
|
|
36
|
+
3. **Write the plan content** — Use \`craft_update_plan\` to write the plan body in Markdown:
|
|
37
|
+
- **Goal**: What the user wants to achieve
|
|
38
|
+
- **Context**: Relevant background, constraints, existing code
|
|
39
|
+
- **Approach**: High-level strategy and technical decisions
|
|
40
|
+
- **Steps**: Ordered implementation steps
|
|
41
|
+
|
|
42
|
+
4. **Add tasks** — Use \`craft_add_task\` to break the plan into concrete, actionable tasks. Each task should be independently verifiable. Add acceptance criteria inline when helpful.
|
|
43
|
+
|
|
44
|
+
5. **Iterate** — The user may request changes. Use \`craft_update_plan\`, \`craft_add_task\`, and \`craft_list_tasks\` to refine until the user is satisfied.
|
|
45
|
+
|
|
46
|
+
6. **Confirm and finalize** — When the plan is thorough and the user confirms, call \`craft_set_status("formed")\`. Then remind the user they can switch to Build mode to execute.
|
|
47
|
+
|
|
48
|
+
7. **Cancel a plan** — If the user wants to abandon the plan at any stage (forming or formed), call \`craft_set_status("cancelled")\`. This frees up the active slot so a new plan can be created immediately.
|
|
49
|
+
|
|
50
|
+
## Guidelines
|
|
51
|
+
|
|
52
|
+
- Always check for an existing active plan with \`craft_get_active_plan\` before creating a new one.
|
|
53
|
+
- If the user seems uncertain or changes their mind, proactively remind them they can cancel the plan.
|
|
54
|
+
- Tasks should be ordered by dependency — a task should not depend on a later task.
|
|
55
|
+
- Keep task scope reasonable — each task should represent 1-5 minutes of focused work.
|
|
56
|
+
- When the user says "looks good", "approved", "go ahead" etc., confirm explicitly then call \`craft_set_status("formed")\`.
|
|
57
|
+
- Never set status to "completed" yourself — that is the Build agent's responsibility after execution.`,
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
"experimental.chat.system.transform": async (
|
|
62
|
+
_input: unknown,
|
|
63
|
+
output: { system?: string }
|
|
64
|
+
) => {
|
|
65
|
+
const active = readActivePlan()
|
|
66
|
+
if (!active || !isActiveStatus(active.status)) return
|
|
67
|
+
|
|
68
|
+
const plan = readPlan(active.planId)
|
|
69
|
+
const taskSummary =
|
|
70
|
+
plan && plan.meta.tasks.length > 0
|
|
71
|
+
? plan.meta.tasks
|
|
72
|
+
.map(
|
|
73
|
+
(t) =>
|
|
74
|
+
` ${t.status === "completed" ? "[x]" : "[ ]"} ${t.title}`
|
|
75
|
+
)
|
|
76
|
+
.join("\n")
|
|
77
|
+
: " (暂无任务)"
|
|
78
|
+
|
|
79
|
+
const block = [
|
|
80
|
+
"",
|
|
81
|
+
"---",
|
|
82
|
+
"## Active Craft Plan",
|
|
83
|
+
"",
|
|
84
|
+
`A craft plan is **${active.status}** and ready:`,
|
|
85
|
+
"",
|
|
86
|
+
`- **Plan ID**: \`${active.planId}\``,
|
|
87
|
+
`- **Name**: ${plan?.meta.name ?? active.planId}`,
|
|
88
|
+
"",
|
|
89
|
+
"**Tasks**:",
|
|
90
|
+
taskSummary,
|
|
91
|
+
"",
|
|
92
|
+
`Use \`craft_get_active_plan\` to read the full plan. Use \`craft_update_task\` to mark tasks complete as you work. When all tasks are done, call \`craft_set_status("completed")\`.`,
|
|
93
|
+
].join("\n")
|
|
94
|
+
|
|
95
|
+
output.system = (output.system ?? "") + block
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
tool: {
|
|
99
|
+
craft_create_plan: tool({
|
|
100
|
+
description:
|
|
101
|
+
"Create a new craft plan. Only one active plan can exist at a time. Returns an error if another plan is already forming or formed.",
|
|
102
|
+
args: {
|
|
103
|
+
name: tool.schema
|
|
104
|
+
.string()
|
|
105
|
+
.describe(
|
|
106
|
+
"A short descriptive name for the plan, e.g. 'add-dark-mode'. Used to generate the plan ID."
|
|
107
|
+
),
|
|
108
|
+
content: tool.schema
|
|
109
|
+
.string()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Optional initial plan content in Markdown."),
|
|
112
|
+
},
|
|
113
|
+
async execute(args: { name: string; content?: string }) {
|
|
114
|
+
const err = requireNoActivePlan()
|
|
115
|
+
if (err) return err
|
|
116
|
+
|
|
117
|
+
const planId = generatePlanId(args.name)
|
|
118
|
+
const now = new Date().toISOString()
|
|
119
|
+
const meta: PlanMeta = {
|
|
120
|
+
name: args.name,
|
|
121
|
+
status: "forming",
|
|
122
|
+
created: now,
|
|
123
|
+
updated: now,
|
|
124
|
+
tasks: [],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
writePlan(
|
|
128
|
+
planId,
|
|
129
|
+
meta,
|
|
130
|
+
args.content ??
|
|
131
|
+
`# Plan: ${args.name}\n\n## 目标\n\n待补充...\n\n## 实现步骤\n\n待补充...\n`
|
|
132
|
+
)
|
|
133
|
+
writeActivePlan({ planId, status: "forming" })
|
|
134
|
+
|
|
135
|
+
return [
|
|
136
|
+
`计划已创建:`,
|
|
137
|
+
`- **ID**: \`${planId}\``,
|
|
138
|
+
`- **名称**: ${args.name}`,
|
|
139
|
+
`- **状态**: forming`,
|
|
140
|
+
``,
|
|
141
|
+
`现在可以完善计划内容了。使用 \`craft_update_plan\` 编写计划正文,使用 \`craft_add_task\` 添加任务项。`,
|
|
142
|
+
].join("\n")
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
craft_get_active_plan: tool({
|
|
147
|
+
description:
|
|
148
|
+
"Get the currently active craft plan. Returns null if no active plan exists.",
|
|
149
|
+
args: {},
|
|
150
|
+
async execute() {
|
|
151
|
+
const active = readActivePlan()
|
|
152
|
+
if (!active) return "当前没有活跃的计划。"
|
|
153
|
+
const plan = readPlan(active.planId)
|
|
154
|
+
if (!plan) {
|
|
155
|
+
clearActivePlan()
|
|
156
|
+
return "活跃计划文件丢失,已自动清除活跃状态。请重新创建计划。"
|
|
157
|
+
}
|
|
158
|
+
const tasks =
|
|
159
|
+
plan.meta.tasks.length > 0
|
|
160
|
+
? plan.meta.tasks
|
|
161
|
+
.map(
|
|
162
|
+
(t) =>
|
|
163
|
+
` - [${t.id}] **${t.title}** (${t.status})`
|
|
164
|
+
)
|
|
165
|
+
.join("\n")
|
|
166
|
+
: " (暂无任务)"
|
|
167
|
+
return [
|
|
168
|
+
`# Plan: ${plan.meta.name}`,
|
|
169
|
+
`- **ID**: \`${plan.planId}\``,
|
|
170
|
+
`- **状态**: ${plan.meta.status}`,
|
|
171
|
+
`- **创建**: ${plan.meta.created}`,
|
|
172
|
+
`- **更新**: ${plan.meta.updated}`,
|
|
173
|
+
``,
|
|
174
|
+
`## 任务`,
|
|
175
|
+
tasks,
|
|
176
|
+
``,
|
|
177
|
+
`## 内容`,
|
|
178
|
+
plan.content || "(空)",
|
|
179
|
+
].join("\n")
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
craft_update_plan: tool({
|
|
184
|
+
description:
|
|
185
|
+
"Update the active plan's content or name. The plan must be in 'forming' status.",
|
|
186
|
+
args: {
|
|
187
|
+
content: tool.schema
|
|
188
|
+
.string()
|
|
189
|
+
.optional()
|
|
190
|
+
.describe(
|
|
191
|
+
"Updated plan content in Markdown. If omitted, content is unchanged."
|
|
192
|
+
),
|
|
193
|
+
name: tool.schema
|
|
194
|
+
.string()
|
|
195
|
+
.optional()
|
|
196
|
+
.describe("Updated plan name. If omitted, name is unchanged."),
|
|
197
|
+
},
|
|
198
|
+
async execute(args: { content?: string; name?: string }) {
|
|
199
|
+
const plan = getActivePlan()
|
|
200
|
+
if (!plan) return "没有活跃的计划。请先用 craft_create_plan 创建一个。"
|
|
201
|
+
if (plan.meta.status !== "forming") {
|
|
202
|
+
return `计划状态为 "${plan.meta.status}",无法修改。只有 "forming" 状态的计划可以修改。`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const updatedMeta = { ...plan.meta, updated: new Date().toISOString() }
|
|
206
|
+
if (args.name) updatedMeta.name = args.name
|
|
207
|
+
|
|
208
|
+
writePlan(
|
|
209
|
+
plan.planId,
|
|
210
|
+
updatedMeta,
|
|
211
|
+
args.content !== undefined ? args.content : plan.content
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const changed: string[] = []
|
|
215
|
+
if (args.content !== undefined) changed.push("内容")
|
|
216
|
+
if (args.name) changed.push("名称")
|
|
217
|
+
return `计划已更新 (${changed.join(", ")}).`
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
|
|
221
|
+
craft_set_status: tool({
|
|
222
|
+
description:
|
|
223
|
+
"Set the plan status. Valid transitions: forming→formed, forming→cancelled, formed→completed, formed→cancelled.",
|
|
224
|
+
args: {
|
|
225
|
+
status: tool.schema
|
|
226
|
+
.string()
|
|
227
|
+
.describe(
|
|
228
|
+
"Target status: 'formed' (plan ready for execution), 'completed' (all tasks done), or 'cancelled' (abandon plan)."
|
|
229
|
+
),
|
|
230
|
+
},
|
|
231
|
+
async execute(args: { status: string }) {
|
|
232
|
+
const to = args.status as PlanStatus
|
|
233
|
+
if (!["formed", "completed", "cancelled"].includes(to)) {
|
|
234
|
+
return `无效的状态: "${to}"。可选值: formed, completed, cancelled。`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const plan = getActivePlan()
|
|
238
|
+
if (!plan) return "没有活跃的计划。"
|
|
239
|
+
|
|
240
|
+
if (!isValidTransition(plan.meta.status, to)) {
|
|
241
|
+
return `无法从 "${plan.meta.status}" 转换到 "${to}"。允许的转换: forming→formed/cancelled, formed→completed/cancelled。`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const updatedMeta = { ...plan.meta, status: to, updated: new Date().toISOString() }
|
|
245
|
+
writePlan(plan.planId, updatedMeta, plan.content)
|
|
246
|
+
|
|
247
|
+
if (to === "completed" || to === "cancelled") {
|
|
248
|
+
clearActivePlan()
|
|
249
|
+
} else {
|
|
250
|
+
writeActivePlan({ planId: plan.planId, status: to })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const messages: Record<PlanStatus, string> = {
|
|
254
|
+
formed:
|
|
255
|
+
"计划已标记为 **formed** (已成型)。你可以切换到 Build 模式开始执行了。",
|
|
256
|
+
completed:
|
|
257
|
+
"计划已标记为 **completed** (已完成)。所有任务已完成,计划已归档。",
|
|
258
|
+
cancelled:
|
|
259
|
+
"计划已 **取消**。现在可以创建新计划了。",
|
|
260
|
+
}
|
|
261
|
+
return messages[to]
|
|
262
|
+
},
|
|
263
|
+
}),
|
|
264
|
+
|
|
265
|
+
craft_add_task: tool({
|
|
266
|
+
description:
|
|
267
|
+
"Add a task to the active plan. The plan must be in 'forming' status.",
|
|
268
|
+
args: {
|
|
269
|
+
title: tool.schema
|
|
270
|
+
.string()
|
|
271
|
+
.describe(
|
|
272
|
+
"Task description. Should be a concrete, actionable item."
|
|
273
|
+
),
|
|
274
|
+
},
|
|
275
|
+
async execute(args: { title: string }) {
|
|
276
|
+
const plan = getActivePlan()
|
|
277
|
+
if (!plan) return "没有活跃的计划。"
|
|
278
|
+
if (plan.meta.status !== "forming") {
|
|
279
|
+
return `计划状态为 "${plan.meta.status}",无法添加任务。`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const nextId = String(plan.meta.tasks.length + 1)
|
|
283
|
+
const newTask = { id: nextId, title: args.title, status: "pending" as TaskStatus }
|
|
284
|
+
const updatedMeta = {
|
|
285
|
+
...plan.meta,
|
|
286
|
+
tasks: [...plan.meta.tasks, newTask],
|
|
287
|
+
updated: new Date().toISOString(),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
writePlan(plan.planId, updatedMeta, plan.content)
|
|
291
|
+
|
|
292
|
+
return `任务已添加: [${nextId}] **${args.title}** (状态: pending)`
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
|
|
296
|
+
craft_update_task: tool({
|
|
297
|
+
description:
|
|
298
|
+
"Update a task's status. Use this during Build mode to mark tasks as in_progress or completed as you work through the plan.",
|
|
299
|
+
args: {
|
|
300
|
+
taskId: tool.schema
|
|
301
|
+
.string()
|
|
302
|
+
.describe("The task ID (as shown in craft_list_tasks)."),
|
|
303
|
+
status: tool.schema
|
|
304
|
+
.string()
|
|
305
|
+
.describe(
|
|
306
|
+
"New status: 'pending', 'in_progress', or 'completed'."
|
|
307
|
+
),
|
|
308
|
+
},
|
|
309
|
+
async execute(args: { taskId: string; status: string }) {
|
|
310
|
+
const to = args.status as TaskStatus
|
|
311
|
+
if (!["pending", "in_progress", "completed"].includes(to)) {
|
|
312
|
+
return `无效的任务状态: "${to}"。可选: pending, in_progress, completed。`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const plan = getActivePlan()
|
|
316
|
+
if (!plan) return "没有活跃的计划。"
|
|
317
|
+
|
|
318
|
+
const idx = plan.meta.tasks.findIndex((t) => t.id === args.taskId)
|
|
319
|
+
if (idx === -1) {
|
|
320
|
+
return `找不到任务 ID: ${args.taskId}。使用 craft_list_tasks 查看所有任务。`
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const updatedTasks = plan.meta.tasks.map((t, i) =>
|
|
324
|
+
i === idx ? { ...t, status: to } : t
|
|
325
|
+
)
|
|
326
|
+
const updatedMeta = {
|
|
327
|
+
...plan.meta,
|
|
328
|
+
tasks: updatedTasks,
|
|
329
|
+
updated: new Date().toISOString(),
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
writePlan(plan.planId, updatedMeta, plan.content)
|
|
333
|
+
|
|
334
|
+
const task = updatedTasks[idx]
|
|
335
|
+
const allDone = updatedTasks.every((t) => t.status === "completed")
|
|
336
|
+
const pending = updatedTasks.filter((t) => t.status !== "completed").length
|
|
337
|
+
|
|
338
|
+
let msg = `任务 [${args.taskId}] "${task.title}" 状态已更新为 **${to}**。`
|
|
339
|
+
if (allDone && plan.meta.status === "formed") {
|
|
340
|
+
msg += `\n\n所有 ${updatedTasks.length} 个任务已完成!请调用 craft_set_status("completed") 标记计划为已完成。`
|
|
341
|
+
} else {
|
|
342
|
+
msg += `\n剩余 ${pending}/${updatedTasks.length} 个任务待完成。`
|
|
343
|
+
}
|
|
344
|
+
return msg
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
|
|
348
|
+
craft_list_tasks: tool({
|
|
349
|
+
description:
|
|
350
|
+
"List all tasks in the active craft plan with their statuses.",
|
|
351
|
+
args: {},
|
|
352
|
+
async execute() {
|
|
353
|
+
const plan = getActivePlan()
|
|
354
|
+
if (!plan) return "没有活跃的计划。"
|
|
355
|
+
|
|
356
|
+
if (plan.meta.tasks.length === 0) {
|
|
357
|
+
return "当前计划暂无任务。"
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const lines = plan.meta.tasks.map((t) => {
|
|
361
|
+
const icon =
|
|
362
|
+
t.status === "completed"
|
|
363
|
+
? "✅"
|
|
364
|
+
: t.status === "in_progress"
|
|
365
|
+
? "🔄"
|
|
366
|
+
: "⬜"
|
|
367
|
+
return ` ${icon} [${t.id}] **${t.title}**`
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const done = plan.meta.tasks.filter(
|
|
371
|
+
(t) => t.status === "completed"
|
|
372
|
+
).length
|
|
373
|
+
return [
|
|
374
|
+
`## 任务清单 (${done}/${plan.meta.tasks.length} 完成)`,
|
|
375
|
+
...lines,
|
|
376
|
+
].join("\n")
|
|
377
|
+
},
|
|
378
|
+
}),
|
|
379
|
+
|
|
380
|
+
craft_list_plans: tool({
|
|
381
|
+
description:
|
|
382
|
+
"List all craft plans, optionally filtered by status. Includes both active and archived plans.",
|
|
383
|
+
args: {
|
|
384
|
+
status: tool.schema
|
|
385
|
+
.string()
|
|
386
|
+
.optional()
|
|
387
|
+
.describe(
|
|
388
|
+
"Filter by status: 'forming', 'formed', 'completed', 'cancelled'. Omit for all."
|
|
389
|
+
),
|
|
390
|
+
},
|
|
391
|
+
async execute(args: { status?: string }) {
|
|
392
|
+
const status = args.status as PlanStatus | undefined
|
|
393
|
+
const plans = listPlans(status)
|
|
394
|
+
|
|
395
|
+
if (plans.length === 0) {
|
|
396
|
+
const filter = status ? `(状态: ${status}) ` : ""
|
|
397
|
+
return `没有${filter}计划。`
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const lines = plans.map((p) => {
|
|
401
|
+
const taskDone = p.meta.tasks.filter(
|
|
402
|
+
(t) => t.status === "completed"
|
|
403
|
+
).length
|
|
404
|
+
const taskTotal = p.meta.tasks.length
|
|
405
|
+
const taskInfo =
|
|
406
|
+
taskTotal > 0 ? ` | ${taskDone}/${taskTotal} 任务` : ""
|
|
407
|
+
return ` - \`${p.planId}\` — **${p.meta.name}** [${p.meta.status}]${taskInfo}`
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const active = readActivePlan()
|
|
411
|
+
const activeNote = active
|
|
412
|
+
? `\n> 当前活跃计划: \`${active.planId}\` (${active.status})\n`
|
|
413
|
+
: "\n> 当前没有活跃计划。使用 craft_create_plan 创建一个。\n"
|
|
414
|
+
|
|
415
|
+
return [activeNote, `## 所有计划`, ...lines].join("\n")
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export default craftPlugin
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@straussenl/opencode-craft",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Craft Planner plugin for OpenCode — create structured, executable plans for your work",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": "./index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"state.ts",
|
|
10
|
+
"storage.ts",
|
|
11
|
+
"types.ts"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@opencode-ai/plugin": "latest",
|
|
15
|
+
"gray-matter": "^4.0.3"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT"
|
|
18
|
+
}
|
package/state.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PlanStatus } from "./types"
|
|
2
|
+
import { VALID_TRANSITIONS } from "./types"
|
|
3
|
+
|
|
4
|
+
export function isValidTransition(from: PlanStatus, to: PlanStatus): boolean {
|
|
5
|
+
return VALID_TRANSITIONS[from].includes(to)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isActiveStatus(status: PlanStatus): boolean {
|
|
9
|
+
return status === "forming" || status === "formed"
|
|
10
|
+
}
|
package/storage.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import matter from "gray-matter"
|
|
5
|
+
import type { Plan, PlanMeta, ActivePlan, PlanStatus } from "./types"
|
|
6
|
+
import { isActiveStatus } from "./state"
|
|
7
|
+
|
|
8
|
+
function getCraftDir(): string {
|
|
9
|
+
return join(homedir(), ".config", "opencode", "craft")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getPlansDir(): string {
|
|
13
|
+
return join(getCraftDir(), "plans")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getActiveFilePath(): string {
|
|
17
|
+
return join(getCraftDir(), "active.json")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getPlanPath(planId: string): string {
|
|
21
|
+
return join(getPlansDir(), `${planId}.md`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureDir(path: string): void {
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
mkdirSync(path, { recursive: true })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readActivePlan(): ActivePlan | null {
|
|
31
|
+
const activeFile = getActiveFilePath()
|
|
32
|
+
if (!existsSync(activeFile)) return null
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(activeFile, "utf-8")
|
|
35
|
+
return JSON.parse(raw) as ActivePlan
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeActivePlan(active: ActivePlan): void {
|
|
42
|
+
ensureDir(getCraftDir())
|
|
43
|
+
writeFileSync(getActiveFilePath(), JSON.stringify(active, null, 2), "utf-8")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clearActivePlan(): void {
|
|
47
|
+
const f = getActiveFilePath()
|
|
48
|
+
if (existsSync(f)) unlinkSync(f)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readPlan(planId: string): Plan | null {
|
|
52
|
+
const filePath = getPlanPath(planId)
|
|
53
|
+
if (!existsSync(filePath)) return null
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(filePath, "utf-8")
|
|
56
|
+
const { data, content } = matter(raw)
|
|
57
|
+
return {
|
|
58
|
+
planId,
|
|
59
|
+
meta: {
|
|
60
|
+
name: data.name ?? planId,
|
|
61
|
+
status: data.status ?? "forming",
|
|
62
|
+
created: data.created ?? new Date(0).toISOString(),
|
|
63
|
+
updated: data.updated ?? new Date(0).toISOString(),
|
|
64
|
+
tasks: Array.isArray(data.tasks) ? data.tasks : [],
|
|
65
|
+
},
|
|
66
|
+
content: content.trim(),
|
|
67
|
+
filePath,
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function writePlan(planId: string, meta: PlanMeta, content: string): void {
|
|
75
|
+
ensureDir(getPlansDir())
|
|
76
|
+
const filePath = getPlanPath(planId)
|
|
77
|
+
const text = matter.stringify(content.trim(), meta as unknown as Record<string, unknown>)
|
|
78
|
+
writeFileSync(filePath, text, "utf-8")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function listPlans(status?: PlanStatus): Plan[] {
|
|
82
|
+
const dir = getPlansDir()
|
|
83
|
+
if (!existsSync(dir)) return []
|
|
84
|
+
try {
|
|
85
|
+
const files = readdirSync(dir).filter(f => f.endsWith(".md"))
|
|
86
|
+
const plans: Plan[] = []
|
|
87
|
+
for (const f of files) {
|
|
88
|
+
const planId = f.replace(/\.md$/, "")
|
|
89
|
+
const plan = readPlan(planId)
|
|
90
|
+
if (plan && (!status || plan.meta.status === status)) {
|
|
91
|
+
plans.push(plan)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return plans.sort((a, b) => b.meta.created.localeCompare(a.meta.created))
|
|
95
|
+
} catch {
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function generatePlanId(name: string): string {
|
|
101
|
+
const slug = name
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
|
104
|
+
.replace(/^-|-$/g, "")
|
|
105
|
+
.slice(0, 50)
|
|
106
|
+
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "")
|
|
107
|
+
const suffix = Array.from(crypto.getRandomValues(new Uint8Array(2)))
|
|
108
|
+
.map(b => b.toString(16).padStart(2, "0"))
|
|
109
|
+
.join("")
|
|
110
|
+
return `${slug}-${date}-${suffix}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getActivePlan(): Plan | null {
|
|
114
|
+
const active = readActivePlan()
|
|
115
|
+
if (!active) return null
|
|
116
|
+
return readPlan(active.planId)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function requireNoActivePlan(): string | null {
|
|
120
|
+
const active = readActivePlan()
|
|
121
|
+
if (active && isActiveStatus(active.status)) {
|
|
122
|
+
return `一个活跃计划已存在: **${active.planId}** (状态: ${active.status})。请先完成或取消它再创建新计划。`
|
|
123
|
+
}
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getCraftDirPath(): string {
|
|
128
|
+
return getCraftDir()
|
|
129
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type PlanStatus = "forming" | "formed" | "completed" | "cancelled"
|
|
2
|
+
|
|
3
|
+
export type TaskStatus = "pending" | "in_progress" | "completed"
|
|
4
|
+
|
|
5
|
+
export interface Task {
|
|
6
|
+
id: string
|
|
7
|
+
title: string
|
|
8
|
+
status: TaskStatus
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PlanMeta {
|
|
12
|
+
name: string
|
|
13
|
+
status: PlanStatus
|
|
14
|
+
created: string
|
|
15
|
+
updated: string
|
|
16
|
+
tasks: Task[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ActivePlan {
|
|
20
|
+
planId: string
|
|
21
|
+
status: PlanStatus
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Plan {
|
|
25
|
+
planId: string
|
|
26
|
+
meta: PlanMeta
|
|
27
|
+
content: string
|
|
28
|
+
filePath: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const VALID_TRANSITIONS: Record<PlanStatus, PlanStatus[]> = {
|
|
32
|
+
forming: ["formed", "cancelled"],
|
|
33
|
+
formed: ["completed", "cancelled"],
|
|
34
|
+
completed: [],
|
|
35
|
+
cancelled: [],
|
|
36
|
+
}
|