@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.
Files changed (5) hide show
  1. package/index.ts +422 -0
  2. package/package.json +18 -0
  3. package/state.ts +10 -0
  4. package/storage.ts +129 -0
  5. 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
+ }