opencode-agile-agent 1.0.4 → 1.2.1
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 +78 -14
- package/bin/cli.js +180 -254
- package/bin/sync-templates.js +1 -7
- package/bin/validate-templates.js +17 -19
- package/package.json +1 -1
- package/templates/.opencode/ARCHITECTURE.md +94 -64
- package/templates/.opencode/README.md +115 -63
- package/templates/.opencode/agents/archiver.md +45 -0
- package/templates/.opencode/agents/backend-specialist.md +43 -46
- package/templates/.opencode/agents/context-gatherer.md +26 -26
- package/templates/.opencode/agents/debugger.md +45 -45
- package/templates/.opencode/agents/developer.md +54 -45
- package/templates/.opencode/agents/devops-engineer.md +42 -45
- package/templates/.opencode/agents/feature-lead.md +81 -50
- package/templates/.opencode/agents/frontend-specialist.md +44 -46
- package/templates/.opencode/agents/performance-optimizer.md +45 -45
- package/templates/.opencode/agents/pr-reviewer.md +46 -45
- package/templates/.opencode/agents/project-planner.md +41 -45
- package/templates/.opencode/agents/retrospective-writer.md +48 -0
- package/templates/.opencode/agents/security-auditor.md +39 -45
- package/templates/.opencode/agents/system-analyst.md +43 -43
- package/templates/.opencode/agents/test-engineer.md +44 -44
- package/templates/.opencode/bun.lock +18 -0
- package/templates/.opencode/commands/archive.md +15 -0
- package/templates/.opencode/commands/assign-models.md +39 -0
- package/templates/.opencode/commands/brainstorm.md +5 -2
- package/templates/.opencode/commands/check-progress.md +21 -0
- package/templates/.opencode/commands/create.md +8 -3
- package/templates/.opencode/commands/plan.md +7 -2
- package/templates/.opencode/commands/reframe.md +17 -0
- package/templates/.opencode/commands/review.md +9 -3
- package/templates/.opencode/commands/rubber-duck.md +14 -0
- package/templates/.opencode/commands/status.md +3 -0
- package/templates/.opencode/commands/test.md +8 -3
- package/templates/.opencode/config.template.json +160 -20
- package/templates/.opencode/package-lock.json +115 -0
- package/templates/.opencode/package.json +6 -0
- package/templates/.opencode/plugins/session-artifacts.ts +611 -0
- package/templates/.opencode/skills/archive-writing/SKILL.md +36 -0
- package/templates/.opencode/skills/artifact-discipline/SKILL.md +30 -0
- package/templates/.opencode/skills/clarify-first/SKILL.md +34 -0
- package/templates/.opencode/skills/context-archive/SKILL.md +10 -26
- package/templates/.opencode/skills/context-gathering/SKILL.md +2 -0
- package/templates/.opencode/skills/intelligent-routing/SKILL.md +10 -2
- package/templates/.opencode/skills/plan-writing/SKILL.md +5 -5
- package/templates/.opencode/templates/brief.template.md +25 -0
- package/templates/.opencode/templates/notes.template.md +13 -0
- package/templates/.opencode/templates/review-summary.template.md +6 -0
- package/templates/.opencode/templates/session-summary.template.md +7 -0
- package/templates/.opencode/templates/spec.template.md +17 -0
- package/templates/.opencode/templates/status.template.yaml +14 -0
- package/templates/.opencode/templates/task.template.md +5 -0
- package/templates/opencode.json +12 -0
- package/templates/.opencode/agents/api-designer.md +0 -45
- package/templates/.opencode/agents/code-archaeologist.md +0 -45
- package/templates/.opencode/agents/database-architect.md +0 -45
- package/templates/.opencode/agents/documentation-writer.md +0 -45
- package/templates/.opencode/agents/explorer-agent.md +0 -55
- package/templates/.opencode/agents/game-developer.md +0 -45
- package/templates/.opencode/agents/mobile-developer.md +0 -45
- package/templates/.opencode/agents/orchestrator.md +0 -48
- package/templates/.opencode/agents/penetration-tester.md +0 -46
- package/templates/.opencode/agents/product-manager.md +0 -46
- package/templates/.opencode/agents/qa-automation-engineer.md +0 -46
- package/templates/.opencode/agents/seo-specialist.md +0 -45
- package/templates/.opencode/archive/README.md +0 -24
- package/templates/.opencode/commands/debug.md +0 -10
- package/templates/.opencode/skills/parallel-agents/SKILL.md +0 -38
- package/templates/.opencode/skills/redteam-validation/SKILL.md +0 -33
- package/templates/AGENTS.template.md +0 -300
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { execFileSync } from "child_process"
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs"
|
|
4
|
+
import { dirname, join } from "path"
|
|
5
|
+
import YAML from "yaml"
|
|
6
|
+
|
|
7
|
+
type StatusState = "brainstorm" | "planning" | "implementation" | "verification" | "review" | "done" | "blocked"
|
|
8
|
+
|
|
9
|
+
type StatusDocument = {
|
|
10
|
+
feature: string
|
|
11
|
+
owner: string
|
|
12
|
+
handoff_to: string
|
|
13
|
+
stage: StatusState
|
|
14
|
+
status: StatusState
|
|
15
|
+
summary: string
|
|
16
|
+
next_step: string
|
|
17
|
+
updated_at: string
|
|
18
|
+
approved_scope: string[]
|
|
19
|
+
open_questions: string[]
|
|
20
|
+
risks: string[]
|
|
21
|
+
blockers: string[]
|
|
22
|
+
files_of_interest: string[]
|
|
23
|
+
changed_files: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type PluginContext = {
|
|
27
|
+
directory: string
|
|
28
|
+
client: any
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FEATURE_ROOT = ["artifacts", "features"]
|
|
32
|
+
const ACTIVE_FEATURE_FILE = ["artifacts", "active-feature.txt"]
|
|
33
|
+
const ARCHIVE_ROOT = ["archive"]
|
|
34
|
+
const TEMPLATE_ROOT = ["templates"]
|
|
35
|
+
|
|
36
|
+
const DEFAULT_STATUS = (feature: string): StatusDocument => ({
|
|
37
|
+
feature,
|
|
38
|
+
owner: "feature-lead",
|
|
39
|
+
handoff_to: "context-gatherer",
|
|
40
|
+
stage: "brainstorm",
|
|
41
|
+
status: "brainstorm",
|
|
42
|
+
summary: "New feature conversation started.",
|
|
43
|
+
next_step: "Clarify the request and decide whether to promote it into planning.",
|
|
44
|
+
updated_at: new Date().toISOString(),
|
|
45
|
+
approved_scope: [],
|
|
46
|
+
open_questions: [],
|
|
47
|
+
risks: [],
|
|
48
|
+
blockers: [],
|
|
49
|
+
files_of_interest: [],
|
|
50
|
+
changed_files: [],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const slugify = (value: string) =>
|
|
54
|
+
value
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
57
|
+
.replace(/^-+|-+$/g, "") || "active-feature"
|
|
58
|
+
|
|
59
|
+
const ensureDir = (path: string) => {
|
|
60
|
+
if (!existsSync(path)) mkdirSync(path, { recursive: true })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const readText = (path: string) => (existsSync(path) ? readFileSync(path, "utf8") : "")
|
|
64
|
+
|
|
65
|
+
const writeText = (path: string, content: string) => {
|
|
66
|
+
ensureDir(dirname(path))
|
|
67
|
+
writeFileSync(path, content)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const appendJsonLine = (path: string, record: Record<string, unknown>) => {
|
|
71
|
+
const current = readText(path)
|
|
72
|
+
writeText(path, `${current}${JSON.stringify(record)}\n`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const templatePath = (ctx: PluginContext, file: string) => join(ctx.directory, ...TEMPLATE_ROOT, file)
|
|
76
|
+
const featureRoot = (ctx: PluginContext) => join(ctx.directory, ...FEATURE_ROOT)
|
|
77
|
+
const featurePath = (ctx: PluginContext, feature: string) => join(featureRoot(ctx), feature)
|
|
78
|
+
const archiveRoot = (ctx: PluginContext) => join(ctx.directory, ...ARCHIVE_ROOT)
|
|
79
|
+
const activeFeatureFile = (ctx: PluginContext) => join(ctx.directory, ...ACTIVE_FEATURE_FILE)
|
|
80
|
+
const statusPath = (ctx: PluginContext, feature: string) => join(featurePath(ctx, feature), "status.yaml")
|
|
81
|
+
const handoffPath = (ctx: PluginContext, feature: string) => join(featurePath(ctx, feature), "handoff", "latest.md")
|
|
82
|
+
const notesPath = (ctx: PluginContext, feature: string) => join(featurePath(ctx, feature), "notes.md")
|
|
83
|
+
const archiveSummaryPath = (ctx: PluginContext, feature: string) => join(archiveRoot(ctx), `${feature}.md`)
|
|
84
|
+
|
|
85
|
+
const resolveCurrentFeature = (ctx: PluginContext) => {
|
|
86
|
+
const current = readText(activeFeatureFile(ctx)).trim()
|
|
87
|
+
if (current) return current
|
|
88
|
+
|
|
89
|
+
const root = featureRoot(ctx)
|
|
90
|
+
if (!existsSync(root)) return ""
|
|
91
|
+
const dirs = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory())
|
|
92
|
+
return dirs[0]?.name ?? ""
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const saveCurrentFeature = (ctx: PluginContext, feature: string) => {
|
|
96
|
+
writeText(activeFeatureFile(ctx), `${feature}\n`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ensureFeatureScaffold = (ctx: PluginContext, feature: string) => {
|
|
100
|
+
const root = featurePath(ctx, feature)
|
|
101
|
+
ensureDir(root)
|
|
102
|
+
ensureDir(join(root, "handoff"))
|
|
103
|
+
ensureDir(join(root, "sessions"))
|
|
104
|
+
ensureDir(join(root, "evidence"))
|
|
105
|
+
|
|
106
|
+
const templateFiles = [
|
|
107
|
+
["brief.template.md", "brief.md"],
|
|
108
|
+
["spec.template.md", "spec.md"],
|
|
109
|
+
["task.template.md", "task.md"],
|
|
110
|
+
["notes.template.md", "notes.md"],
|
|
111
|
+
] as const
|
|
112
|
+
|
|
113
|
+
for (const [template, target] of templateFiles) {
|
|
114
|
+
const nextPath = join(root, target)
|
|
115
|
+
if (!existsSync(nextPath)) writeText(nextPath, readText(templatePath(ctx, template)))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!existsSync(statusPath(ctx, feature))) {
|
|
119
|
+
saveStatus(ctx, feature, DEFAULT_STATUS(feature))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const loadStatus = (ctx: PluginContext, feature: string): StatusDocument => {
|
|
124
|
+
ensureFeatureScaffold(ctx, feature)
|
|
125
|
+
const parsed = YAML.parse(readText(statusPath(ctx, feature))) as Partial<StatusDocument> | null
|
|
126
|
+
return {
|
|
127
|
+
...DEFAULT_STATUS(feature),
|
|
128
|
+
...(parsed ?? {}),
|
|
129
|
+
feature,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const saveStatus = (ctx: PluginContext, feature: string, status: StatusDocument) => {
|
|
134
|
+
writeText(statusPath(ctx, feature), YAML.stringify(status))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const unique = (values: string[]) => [...new Set(values.filter(Boolean))]
|
|
138
|
+
|
|
139
|
+
const sectionList = (items: string[]) => (items.length ? items.map((item) => `- ${item}`).join("\n") : "- None")
|
|
140
|
+
|
|
141
|
+
const buildHandoff = (status: StatusDocument, target: string) => {
|
|
142
|
+
const lines = [
|
|
143
|
+
"# Handoff",
|
|
144
|
+
"",
|
|
145
|
+
`- Feature: ${status.feature}`,
|
|
146
|
+
`- Stage: ${status.stage}`,
|
|
147
|
+
`- Requested owner: ${target}`,
|
|
148
|
+
`- Current owner: ${status.owner}`,
|
|
149
|
+
`- Summary: ${status.summary}`,
|
|
150
|
+
`- Next step: ${status.next_step}`,
|
|
151
|
+
"",
|
|
152
|
+
"## Approved Scope",
|
|
153
|
+
sectionList(status.approved_scope),
|
|
154
|
+
"",
|
|
155
|
+
"## Open Questions",
|
|
156
|
+
sectionList(status.open_questions),
|
|
157
|
+
"",
|
|
158
|
+
"## Risks",
|
|
159
|
+
sectionList(status.risks),
|
|
160
|
+
"",
|
|
161
|
+
"## Blockers",
|
|
162
|
+
sectionList(status.blockers),
|
|
163
|
+
"",
|
|
164
|
+
"## Files Of Interest",
|
|
165
|
+
sectionList(status.files_of_interest),
|
|
166
|
+
"",
|
|
167
|
+
"## Changed Files",
|
|
168
|
+
sectionList(status.changed_files),
|
|
169
|
+
]
|
|
170
|
+
return lines.join("\n")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const refreshHandoff = (ctx: PluginContext, feature: string, target: string, status: StatusDocument) => {
|
|
174
|
+
writeText(handoffPath(ctx, feature), `${buildHandoff(status, target)}\n`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const updateNotes = (ctx: PluginContext, feature: string, label: string, items: string[]) => {
|
|
178
|
+
if (!items.length) return
|
|
179
|
+
const path = notesPath(ctx, feature)
|
|
180
|
+
const current = readText(path)
|
|
181
|
+
const block = [``, `## ${label}`, ...items.map((item) => `- ${item}`)].join("\n")
|
|
182
|
+
writeText(path, `${current.trimEnd()}\n${block}\n`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fileContents = (ctx: PluginContext, feature: string, name: string) => {
|
|
186
|
+
const path = join(featurePath(ctx, feature), name)
|
|
187
|
+
return readText(path).trim()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const specAcceptanceCriteria = (ctx: PluginContext, feature: string) => {
|
|
191
|
+
const spec = fileContents(ctx, feature, "spec.md")
|
|
192
|
+
if (!spec) return "Acceptance criteria not written yet."
|
|
193
|
+
const match = spec.match(/## Acceptance Criteria\s*([\s\S]*?)(\n## |$)/)
|
|
194
|
+
return match?.[1]?.trim() || "Acceptance criteria not written yet."
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const reviewPacket = (ctx: PluginContext, feature: string) => {
|
|
198
|
+
const status = loadStatus(ctx, feature)
|
|
199
|
+
const lines = [
|
|
200
|
+
"# Review Packet",
|
|
201
|
+
"",
|
|
202
|
+
`- Feature: ${status.feature}`,
|
|
203
|
+
`- Stage: ${status.stage}`,
|
|
204
|
+
`- Summary: ${status.summary}`,
|
|
205
|
+
`- Next step: ${status.next_step}`,
|
|
206
|
+
"",
|
|
207
|
+
"## Approved Scope",
|
|
208
|
+
sectionList(status.approved_scope),
|
|
209
|
+
"",
|
|
210
|
+
"## Acceptance Criteria",
|
|
211
|
+
specAcceptanceCriteria(ctx, feature),
|
|
212
|
+
"",
|
|
213
|
+
"## Risks",
|
|
214
|
+
sectionList(status.risks),
|
|
215
|
+
"",
|
|
216
|
+
"## Changed Files",
|
|
217
|
+
sectionList(status.changed_files),
|
|
218
|
+
]
|
|
219
|
+
return lines.join("\n")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const archiveSummary = (ctx: PluginContext, feature: string, finalSummary: string) => {
|
|
223
|
+
const status = loadStatus(ctx, feature)
|
|
224
|
+
const lines = [
|
|
225
|
+
`# ${feature}`,
|
|
226
|
+
"",
|
|
227
|
+
`- Archived: ${new Date().toISOString()}`,
|
|
228
|
+
`- Final stage: ${status.stage}`,
|
|
229
|
+
`- Owner: ${status.owner}`,
|
|
230
|
+
`- Summary: ${status.summary}`,
|
|
231
|
+
"",
|
|
232
|
+
"## What Was Done",
|
|
233
|
+
finalSummary.trim(),
|
|
234
|
+
"",
|
|
235
|
+
"## Approved Scope",
|
|
236
|
+
sectionList(status.approved_scope),
|
|
237
|
+
"",
|
|
238
|
+
"## Changed Files",
|
|
239
|
+
sectionList(status.changed_files),
|
|
240
|
+
"",
|
|
241
|
+
"## Risks Or Follow-Up",
|
|
242
|
+
sectionList(unique([...status.risks, ...status.blockers, ...status.open_questions])),
|
|
243
|
+
]
|
|
244
|
+
return lines.join("\n") + "\n"
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const runGit = (ctx: PluginContext, args: string[]) => {
|
|
248
|
+
try {
|
|
249
|
+
return execFileSync("git", ["-C", ctx.directory, ...args], { encoding: "utf8" }).trim()
|
|
250
|
+
} catch {
|
|
251
|
+
return ""
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const repoDelta = (ctx: PluginContext, feature: string) => {
|
|
256
|
+
const status = loadStatus(ctx, feature)
|
|
257
|
+
const porcelain = runGit(ctx, ["status", "--porcelain"])
|
|
258
|
+
const trackedArtifact = unique([...status.changed_files, ...status.files_of_interest])
|
|
259
|
+
|
|
260
|
+
const gitChanged = unique(
|
|
261
|
+
porcelain
|
|
262
|
+
.split(/\r?\n/)
|
|
263
|
+
.map((line) => line.trimEnd())
|
|
264
|
+
.filter(Boolean)
|
|
265
|
+
.map((line) => line.slice(3).replace(/\\/g, "/")),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const artifactOnly = trackedArtifact.filter((file) => !gitChanged.includes(file.replace(/\\/g, "/")))
|
|
269
|
+
const gitOnly = gitChanged.filter((file) => !trackedArtifact.includes(file))
|
|
270
|
+
const clean = artifactOnly.length === 0 && gitOnly.length === 0
|
|
271
|
+
|
|
272
|
+
const lines = [
|
|
273
|
+
"# Repo Delta",
|
|
274
|
+
"",
|
|
275
|
+
`- Feature: ${feature}`,
|
|
276
|
+
`- Clean match: ${clean ? "yes" : "no"}`,
|
|
277
|
+
"",
|
|
278
|
+
"## Artifact Files",
|
|
279
|
+
sectionList(trackedArtifact),
|
|
280
|
+
"",
|
|
281
|
+
"## Git Changed Files",
|
|
282
|
+
sectionList(gitChanged),
|
|
283
|
+
"",
|
|
284
|
+
"## Artifact Only",
|
|
285
|
+
sectionList(artifactOnly),
|
|
286
|
+
"",
|
|
287
|
+
"## Git Only",
|
|
288
|
+
sectionList(gitOnly),
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
return lines.join("\n")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const archiveReadiness = (ctx: PluginContext, feature: string) => {
|
|
295
|
+
ensureFeatureScaffold(ctx, feature)
|
|
296
|
+
const status = loadStatus(ctx, feature)
|
|
297
|
+
const required = ["brief.md", "spec.md", "task.md", "notes.md", "status.yaml"]
|
|
298
|
+
const missing = required.filter((file) => !existsSync(join(featurePath(ctx, feature), file)))
|
|
299
|
+
const issues = [
|
|
300
|
+
...(missing.length ? [`Missing files: ${missing.join(", ")}`] : []),
|
|
301
|
+
...(status.status !== "done" ? [`status.yaml.status must be done, got ${status.status}`] : []),
|
|
302
|
+
...(!status.summary ? ["summary is empty"] : []),
|
|
303
|
+
...(!status.next_step ? ["next_step is empty"] : []),
|
|
304
|
+
]
|
|
305
|
+
return issues.length ? `NOT READY\n${issues.map((issue) => `- ${issue}`).join("\n")}` : `READY\n- ${feature} can be archived.`
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const createCurrentTool = (ctx: PluginContext) =>
|
|
309
|
+
tool({
|
|
310
|
+
description: "Read the current active feature artifact and return the live execution state.",
|
|
311
|
+
args: {
|
|
312
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
313
|
+
},
|
|
314
|
+
async execute(args) {
|
|
315
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
316
|
+
ensureFeatureScaffold(ctx, feature)
|
|
317
|
+
saveCurrentFeature(ctx, feature)
|
|
318
|
+
const status = loadStatus(ctx, feature)
|
|
319
|
+
return [
|
|
320
|
+
`Feature: ${status.feature}`,
|
|
321
|
+
`Stage: ${status.stage}`,
|
|
322
|
+
`Status: ${status.status}`,
|
|
323
|
+
`Owner: ${status.owner}`,
|
|
324
|
+
`Next: ${status.next_step}`,
|
|
325
|
+
`Summary: ${status.summary}`,
|
|
326
|
+
`Files of interest: ${status.files_of_interest.join(", ") || "none"}`,
|
|
327
|
+
`Changed files: ${status.changed_files.join(", ") || "none"}`,
|
|
328
|
+
].join("\n")
|
|
329
|
+
},
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const createHandoffTool = (ctx: PluginContext) =>
|
|
333
|
+
tool({
|
|
334
|
+
description: "Generate the canonical handoff packet for a target subagent.",
|
|
335
|
+
args: {
|
|
336
|
+
target: tool.schema.string().describe("Target subagent name."),
|
|
337
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
338
|
+
},
|
|
339
|
+
async execute(args) {
|
|
340
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
341
|
+
ensureFeatureScaffold(ctx, feature)
|
|
342
|
+
saveCurrentFeature(ctx, feature)
|
|
343
|
+
const status = loadStatus(ctx, feature)
|
|
344
|
+
refreshHandoff(ctx, feature, args.target, status)
|
|
345
|
+
appendJsonLine(join(featurePath(ctx, feature), "evidence", "handoffs.jsonl"), {
|
|
346
|
+
timestamp: new Date().toISOString(),
|
|
347
|
+
feature,
|
|
348
|
+
target: args.target,
|
|
349
|
+
stage: status.stage,
|
|
350
|
+
})
|
|
351
|
+
return readText(handoffPath(ctx, feature))
|
|
352
|
+
},
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const createSectionTool = (ctx: PluginContext) =>
|
|
356
|
+
tool({
|
|
357
|
+
description: "Read one planning artifact section without loading the full feature bundle.",
|
|
358
|
+
args: {
|
|
359
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
360
|
+
section: tool.schema.enum(["brief", "spec", "task", "notes", "status"]).describe("Artifact section to read."),
|
|
361
|
+
},
|
|
362
|
+
async execute(args) {
|
|
363
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
364
|
+
ensureFeatureScaffold(ctx, feature)
|
|
365
|
+
const map: Record<string, string> = {
|
|
366
|
+
brief: "brief.md",
|
|
367
|
+
spec: "spec.md",
|
|
368
|
+
task: "task.md",
|
|
369
|
+
notes: "notes.md",
|
|
370
|
+
status: "status.yaml",
|
|
371
|
+
}
|
|
372
|
+
const content = fileContents(ctx, feature, map[args.section])
|
|
373
|
+
return content || `${args.section} is empty.`
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
const createChangedFilesTool = (ctx: PluginContext) =>
|
|
378
|
+
tool({
|
|
379
|
+
description: "Read the changed files tracked for the active feature.",
|
|
380
|
+
args: {
|
|
381
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
382
|
+
},
|
|
383
|
+
async execute(args) {
|
|
384
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
385
|
+
const status = loadStatus(ctx, feature)
|
|
386
|
+
return status.changed_files.length ? status.changed_files.join("\n") : "No changed files recorded yet."
|
|
387
|
+
},
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
const createAcceptanceCriteriaTool = (ctx: PluginContext) =>
|
|
391
|
+
tool({
|
|
392
|
+
description: "Read only the acceptance criteria from the active feature spec.",
|
|
393
|
+
args: {
|
|
394
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
395
|
+
},
|
|
396
|
+
async execute(args) {
|
|
397
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
398
|
+
ensureFeatureScaffold(ctx, feature)
|
|
399
|
+
return specAcceptanceCriteria(ctx, feature)
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const createReviewPacketTool = (ctx: PluginContext) =>
|
|
404
|
+
tool({
|
|
405
|
+
description: "Read a review-focused packet with scope, risks, acceptance criteria, and changed files.",
|
|
406
|
+
args: {
|
|
407
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
408
|
+
},
|
|
409
|
+
async execute(args) {
|
|
410
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
411
|
+
ensureFeatureScaffold(ctx, feature)
|
|
412
|
+
return reviewPacket(ctx, feature)
|
|
413
|
+
},
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
const createRepoDeltaTool = (ctx: PluginContext) =>
|
|
417
|
+
tool({
|
|
418
|
+
description: "Compare the live artifact file lists against the actual git working tree changes.",
|
|
419
|
+
args: {
|
|
420
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
421
|
+
},
|
|
422
|
+
async execute(args) {
|
|
423
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
424
|
+
ensureFeatureScaffold(ctx, feature)
|
|
425
|
+
return repoDelta(ctx, feature)
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
const createUpdateTool = (ctx: PluginContext) =>
|
|
430
|
+
tool({
|
|
431
|
+
description: "Update the live artifact state, record checkpoints, and keep the latest handoff packet fresh.",
|
|
432
|
+
args: {
|
|
433
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
434
|
+
summary: tool.schema.string().optional().describe("Short live summary."),
|
|
435
|
+
next_step: tool.schema.string().optional().describe("Next best action."),
|
|
436
|
+
owner: tool.schema.string().optional().describe("Current owner."),
|
|
437
|
+
handoff_to: tool.schema.string().optional().describe("Next owner."),
|
|
438
|
+
stage: tool.schema.enum(["brainstorm", "planning", "implementation", "verification", "review", "done", "blocked"]).optional().describe("Stage transition."),
|
|
439
|
+
approved_scope: tool.schema.array(tool.schema.string()).optional().describe("Approved scope bullets."),
|
|
440
|
+
open_questions: tool.schema.array(tool.schema.string()).optional().describe("Open questions."),
|
|
441
|
+
risks: tool.schema.array(tool.schema.string()).optional().describe("Risks."),
|
|
442
|
+
blockers: tool.schema.array(tool.schema.string()).optional().describe("Blockers."),
|
|
443
|
+
files_of_interest: tool.schema.array(tool.schema.string()).optional().describe("Key files for the next agent."),
|
|
444
|
+
changed_files: tool.schema.array(tool.schema.string()).optional().describe("Files changed in the current stage."),
|
|
445
|
+
facts: tool.schema.array(tool.schema.string()).optional().describe("Facts to append into notes."),
|
|
446
|
+
decisions: tool.schema.array(tool.schema.string()).optional().describe("Decisions to append into notes."),
|
|
447
|
+
links: tool.schema.array(tool.schema.string()).optional().describe("Links to append into notes."),
|
|
448
|
+
},
|
|
449
|
+
async execute(args, toolCtx) {
|
|
450
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
451
|
+
ensureFeatureScaffold(ctx, feature)
|
|
452
|
+
saveCurrentFeature(ctx, feature)
|
|
453
|
+
const current = loadStatus(ctx, feature)
|
|
454
|
+
const next: StatusDocument = {
|
|
455
|
+
...current,
|
|
456
|
+
summary: args.summary ?? current.summary,
|
|
457
|
+
next_step: args.next_step ?? current.next_step,
|
|
458
|
+
owner: args.owner ?? current.owner,
|
|
459
|
+
handoff_to: args.handoff_to ?? current.handoff_to,
|
|
460
|
+
stage: args.stage ?? current.stage,
|
|
461
|
+
status: args.stage ?? current.status,
|
|
462
|
+
updated_at: new Date().toISOString(),
|
|
463
|
+
approved_scope: args.approved_scope ? unique([...current.approved_scope, ...args.approved_scope]) : current.approved_scope,
|
|
464
|
+
open_questions: args.open_questions ? unique([...current.open_questions, ...args.open_questions]) : current.open_questions,
|
|
465
|
+
risks: args.risks ? unique([...current.risks, ...args.risks]) : current.risks,
|
|
466
|
+
blockers: args.blockers ? unique([...current.blockers, ...args.blockers]) : current.blockers,
|
|
467
|
+
files_of_interest: args.files_of_interest ? unique([...current.files_of_interest, ...args.files_of_interest]) : current.files_of_interest,
|
|
468
|
+
changed_files: args.changed_files ? unique([...current.changed_files, ...args.changed_files]) : current.changed_files,
|
|
469
|
+
}
|
|
470
|
+
saveStatus(ctx, feature, next)
|
|
471
|
+
updateNotes(ctx, feature, "Facts", args.facts ?? [])
|
|
472
|
+
updateNotes(ctx, feature, "Decisions", args.decisions ?? [])
|
|
473
|
+
updateNotes(ctx, feature, "Links", args.links ?? [])
|
|
474
|
+
refreshHandoff(ctx, feature, next.handoff_to || toolCtx.agent || "feature-lead", next)
|
|
475
|
+
appendJsonLine(join(featurePath(ctx, feature), "evidence", "checkpoints.jsonl"), {
|
|
476
|
+
timestamp: new Date().toISOString(),
|
|
477
|
+
feature,
|
|
478
|
+
stage: next.stage,
|
|
479
|
+
owner: next.owner,
|
|
480
|
+
next_step: next.next_step,
|
|
481
|
+
})
|
|
482
|
+
return `Updated ${feature} at stage ${next.stage}. Next: ${next.next_step}`
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
const createArchiveCheckTool = (ctx: PluginContext) =>
|
|
487
|
+
tool({
|
|
488
|
+
description: "Check whether the active feature is ready for /archive.",
|
|
489
|
+
args: {
|
|
490
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
491
|
+
},
|
|
492
|
+
async execute(args) {
|
|
493
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
494
|
+
return archiveReadiness(ctx, feature)
|
|
495
|
+
},
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
const createFinalizeTool = (ctx: PluginContext) =>
|
|
499
|
+
tool({
|
|
500
|
+
description: "Finalize the active feature artifact into archive after validation passes.",
|
|
501
|
+
args: {
|
|
502
|
+
feature: tool.schema.string().optional().describe("Feature slug. Uses the active feature when omitted."),
|
|
503
|
+
final_summary: tool.schema.string().describe("Final shipped summary for archive."),
|
|
504
|
+
},
|
|
505
|
+
async execute(args) {
|
|
506
|
+
const feature = slugify(args.feature || resolveCurrentFeature(ctx) || "active-feature")
|
|
507
|
+
ensureFeatureScaffold(ctx, feature)
|
|
508
|
+
const source = featurePath(ctx, feature)
|
|
509
|
+
const readiness = archiveReadiness(ctx, feature)
|
|
510
|
+
if (!String(readiness).startsWith("READY")) return readiness
|
|
511
|
+
|
|
512
|
+
ensureDir(archiveRoot(ctx))
|
|
513
|
+
const target = archiveSummaryPath(ctx, feature)
|
|
514
|
+
writeText(target, archiveSummary(ctx, feature, args.final_summary))
|
|
515
|
+
appendJsonLine(join(source, "evidence", "checkpoints.jsonl"), {
|
|
516
|
+
timestamp: new Date().toISOString(),
|
|
517
|
+
feature,
|
|
518
|
+
stage: "done",
|
|
519
|
+
archived: true,
|
|
520
|
+
})
|
|
521
|
+
return `Archived ${feature} to ${target}`
|
|
522
|
+
},
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
const createEventHandlers = (ctx: PluginContext) => ({
|
|
526
|
+
"session.created": async (event: any) => {
|
|
527
|
+
const feature = resolveCurrentFeature(ctx)
|
|
528
|
+
if (!feature) return
|
|
529
|
+
const sessionID = event?.sessionID || event?.session?.id || "unknown"
|
|
530
|
+
const sessionFile = join(featurePath(ctx, feature), "sessions", `${sessionID}.md`)
|
|
531
|
+
if (!existsSync(sessionFile)) {
|
|
532
|
+
writeText(sessionFile, `# Session ${sessionID}\n\n- Feature: ${feature}\n- Started: ${new Date().toISOString()}\n`)
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
"command.executed": async (event: any) => {
|
|
536
|
+
const feature = resolveCurrentFeature(ctx)
|
|
537
|
+
if (!feature) return
|
|
538
|
+
appendJsonLine(join(featurePath(ctx, feature), "evidence", "commands.jsonl"), {
|
|
539
|
+
timestamp: new Date().toISOString(),
|
|
540
|
+
feature,
|
|
541
|
+
command: event?.command ?? event?.name ?? "unknown",
|
|
542
|
+
})
|
|
543
|
+
},
|
|
544
|
+
"session.idle": async (event: any) => {
|
|
545
|
+
const feature = resolveCurrentFeature(ctx)
|
|
546
|
+
if (!feature) return
|
|
547
|
+
const sessionID = event?.sessionID || event?.session?.id || "unknown"
|
|
548
|
+
const sessionFile = join(featurePath(ctx, feature), "sessions", `${sessionID}.md`)
|
|
549
|
+
const current = readText(sessionFile)
|
|
550
|
+
writeText(sessionFile, `${current.trimEnd()}\n- Idle at: ${new Date().toISOString()}\n`)
|
|
551
|
+
},
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
const createToolHooks = (ctx: PluginContext) => ({
|
|
555
|
+
before: async (input: any) => {
|
|
556
|
+
const feature = resolveCurrentFeature(ctx)
|
|
557
|
+
if (!feature) return
|
|
558
|
+
appendJsonLine(join(featurePath(ctx, feature), "evidence", "tools.jsonl"), {
|
|
559
|
+
timestamp: new Date().toISOString(),
|
|
560
|
+
phase: "before",
|
|
561
|
+
tool: input?.tool ?? "unknown",
|
|
562
|
+
})
|
|
563
|
+
},
|
|
564
|
+
after: async (input: any, output: any) => {
|
|
565
|
+
const feature = resolveCurrentFeature(ctx)
|
|
566
|
+
if (!feature) return
|
|
567
|
+
const status = loadStatus(ctx, feature)
|
|
568
|
+
const args = output?.args ?? input?.args ?? {}
|
|
569
|
+
const changed = typeof args.filePath === "string"
|
|
570
|
+
? [args.filePath]
|
|
571
|
+
: Array.isArray(args.filePaths)
|
|
572
|
+
? args.filePaths.filter((value: unknown) => typeof value === "string")
|
|
573
|
+
: []
|
|
574
|
+
if (changed.length) {
|
|
575
|
+
status.changed_files = unique([...status.changed_files, ...changed])
|
|
576
|
+
status.files_of_interest = unique([...status.files_of_interest, ...changed])
|
|
577
|
+
status.updated_at = new Date().toISOString()
|
|
578
|
+
saveStatus(ctx, feature, status)
|
|
579
|
+
refreshHandoff(ctx, feature, status.handoff_to, status)
|
|
580
|
+
}
|
|
581
|
+
appendJsonLine(join(featurePath(ctx, feature), "evidence", "tools.jsonl"), {
|
|
582
|
+
timestamp: new Date().toISOString(),
|
|
583
|
+
phase: "after",
|
|
584
|
+
tool: input?.tool ?? "unknown",
|
|
585
|
+
changed_files: changed,
|
|
586
|
+
})
|
|
587
|
+
},
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
export default async ({ client, directory }: PluginContext) => {
|
|
591
|
+
const ctx: PluginContext = { client, directory }
|
|
592
|
+
const hooks = createToolHooks(ctx)
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
tool: {
|
|
596
|
+
session_artifact_acceptance_criteria: createAcceptanceCriteriaTool(ctx),
|
|
597
|
+
session_artifact_changed_files: createChangedFilesTool(ctx),
|
|
598
|
+
session_artifact_current: createCurrentTool(ctx),
|
|
599
|
+
session_artifact_handoff: createHandoffTool(ctx),
|
|
600
|
+
session_artifact_repo_delta: createRepoDeltaTool(ctx),
|
|
601
|
+
session_artifact_review_packet: createReviewPacketTool(ctx),
|
|
602
|
+
session_artifact_section: createSectionTool(ctx),
|
|
603
|
+
session_artifact_update: createUpdateTool(ctx),
|
|
604
|
+
session_artifact_archive_check: createArchiveCheckTool(ctx),
|
|
605
|
+
session_artifact_finalize: createFinalizeTool(ctx),
|
|
606
|
+
},
|
|
607
|
+
event: createEventHandlers(ctx),
|
|
608
|
+
"tool.execute.before": hooks.before,
|
|
609
|
+
"tool.execute.after": hooks.after,
|
|
610
|
+
}
|
|
611
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: archive-writing
|
|
3
|
+
description: Write compact archive summaries that explain what shipped, what changed, what was verified, and what follow-up remains.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Archive Writing
|
|
7
|
+
|
|
8
|
+
## Philosophy
|
|
9
|
+
An archive entry should explain the outcome faster than reading the full feature bundle. Preserve signal, not process exhaust.
|
|
10
|
+
|
|
11
|
+
## Use When
|
|
12
|
+
- A slice of work is approved and worth preserving.
|
|
13
|
+
- A feature is complete enough to summarize for future reference.
|
|
14
|
+
- You need a searchable outcome record instead of a draft bundle.
|
|
15
|
+
|
|
16
|
+
## Core Moves
|
|
17
|
+
- Lead with what shipped.
|
|
18
|
+
- Name the changed surfaces explicitly.
|
|
19
|
+
- Include the checks that mattered.
|
|
20
|
+
- Leave unresolved follow-up visible.
|
|
21
|
+
- Keep the record short enough to scan in under a minute.
|
|
22
|
+
|
|
23
|
+
## Recommended Shape
|
|
24
|
+
- What was done
|
|
25
|
+
- Changed files or surfaces
|
|
26
|
+
- Verification signal
|
|
27
|
+
- Risks or follow-up
|
|
28
|
+
|
|
29
|
+
## Anti-Patterns
|
|
30
|
+
- Replaying the full implementation history.
|
|
31
|
+
- Copying planning docs into archive.
|
|
32
|
+
- Claiming certainty when verification was partial.
|
|
33
|
+
- Hiding open questions.
|
|
34
|
+
|
|
35
|
+
## Remember
|
|
36
|
+
The archive is for retrieval later, not for proving how hard the work was.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: artifact-discipline
|
|
3
|
+
description: Keep runtime state in artifacts, retrieve documents through tools, and pass compact intent-only handoffs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Artifact Discipline
|
|
7
|
+
|
|
8
|
+
## Philosophy
|
|
9
|
+
Agents should pass intent, not document dumps. Runtime state belongs in artifacts, and artifacts should be retrieved through tools.
|
|
10
|
+
|
|
11
|
+
## Use When
|
|
12
|
+
- Work spans more than one stage or more than one subagent.
|
|
13
|
+
- You need durable context through compaction or session breaks.
|
|
14
|
+
- You want deterministic handoff instead of full prompt replay.
|
|
15
|
+
|
|
16
|
+
## Core Moves
|
|
17
|
+
- Start from `session_artifact_current` or `session_artifact_handoff`.
|
|
18
|
+
- Carry only goal, stage, constraints, and exit criteria in the prompt.
|
|
19
|
+
- Pull spec, notes, and evidence on demand.
|
|
20
|
+
- Write back structured updates instead of freeform memory.
|
|
21
|
+
- Refresh the handoff packet before delegating.
|
|
22
|
+
|
|
23
|
+
## Anti-Patterns
|
|
24
|
+
- Re-explaining the full feature in every handoff.
|
|
25
|
+
- Treating prompt memory as the source of truth.
|
|
26
|
+
- Updating `status.yaml` manually when an artifact tool can do it.
|
|
27
|
+
- Mixing archived history with active execution state.
|
|
28
|
+
|
|
29
|
+
## Remember
|
|
30
|
+
If a subagent can resume safely after compaction, the harness is doing its job.
|