golden-hoop-spell-opencode 0.1.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 +184 -0
- package/package.json +51 -0
- package/shared/SPIKE_RESULTS.md +597 -0
- package/shared/agents/ghs-context-haiku.md.template +124 -0
- package/shared/agents/ghs-plan-designer.md.template +128 -0
- package/shared/agents/ghs-plan-reviewer.md.template +170 -0
- package/shared/assets/features.json +67 -0
- package/shared/assets/progress.md +35 -0
- package/shared/ghs.default.json +7 -0
- package/shared/ghs.default.json.notes.md +34 -0
- package/shared/ghs.json.example +7 -0
- package/shared/opencode.json.example +11 -0
- package/shared/references/coding-agent.md +533 -0
- package/shared/references/context-snapshot-guide.md +98 -0
- package/shared/references/examples.md +299 -0
- package/shared/references/plan-designer.md +163 -0
- package/shared/references/plan-reviewer.md +193 -0
- package/shared/references/sprint-agent.md +261 -0
- package/src/index.ts +9 -0
- package/src/lib/assets.ts +31 -0
- package/src/lib/codegraph.ts +66 -0
- package/src/lib/config.ts +278 -0
- package/src/lib/nonce.ts +56 -0
- package/src/lib/parse.ts +175 -0
- package/src/lib/paths.ts +26 -0
- package/src/lib/project.ts +28 -0
- package/src/lib/scripts/append-progress-session.ts +178 -0
- package/src/lib/scripts/append-sprint.ts +121 -0
- package/src/lib/scripts/archive-sprint.ts +583 -0
- package/src/lib/scripts/init-project.ts +291 -0
- package/src/lib/scripts/parallel-utils.ts +380 -0
- package/src/lib/scripts/parse-completion-signal.ts +584 -0
- package/src/lib/scripts/parse-delimited-output.ts +632 -0
- package/src/lib/scripts/resolve-project-dir.ts +130 -0
- package/src/lib/scripts/status.ts +292 -0
- package/src/lib/scripts/update-feature-status.ts +169 -0
- package/src/lib/scripts/validate-structure.ts +290 -0
- package/src/lib/state.ts +305 -0
- package/src/plugin.ts +76 -0
- package/src/prompts/context-codegraph.ts +65 -0
- package/src/prompts/context-grep.ts +68 -0
- package/src/prompts/feature-impl.ts +78 -0
- package/src/prompts/plan-designer.ts +59 -0
- package/src/prompts/plan-reviewer.ts +61 -0
- package/src/prompts/sprint-planning.ts +47 -0
- package/src/tools/archive.ts +278 -0
- package/src/tools/code.ts +448 -0
- package/src/tools/config.ts +182 -0
- package/src/tools/force-archive.ts +195 -0
- package/src/tools/init.ts +193 -0
- package/src/tools/plan-finalize.ts +333 -0
- package/src/tools/plan-review.ts +759 -0
- package/src/tools/plan-start.ts +232 -0
- package/src/tools/sprint.ts +213 -0
- package/src/tools/status.ts +51 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
// `ghs-plan-review` tool — the core loop of the 3-role plan dispatcher.
|
|
2
|
+
//
|
|
3
|
+
// This is the middle step of the plan workflow
|
|
4
|
+
// (plan §3.5 / §3.7 / §3.4 D1):
|
|
5
|
+
//
|
|
6
|
+
// ghs-plan-start
|
|
7
|
+
// → [Task: ghs-context-haiku] → ghs-plan-review(snapshot)
|
|
8
|
+
// → [Task: ghs-plan-designer] → ghs-plan-review(plan)
|
|
9
|
+
// → [Task: ghs-plan-reviewer] → ghs-plan-review(review)
|
|
10
|
+
// → ghs-plan-finalize
|
|
11
|
+
//
|
|
12
|
+
// The tool is *one* entry point with *three* modes, selected by which of the
|
|
13
|
+
// `snapshot` / `plan` / `review` string args the caller supplied. The source
|
|
14
|
+
// plugin's SKILL.md ran each parse as a separate Python subprocess invocation
|
|
15
|
+
// with a distinct `--kind`; here we collapse them into a single tool whose
|
|
16
|
+
// mode is disambiguated by Zod (exactly one of the three must be non-empty —
|
|
17
|
+
// plan §5 risk row "ghs-plan-review 的歧义").
|
|
18
|
+
//
|
|
19
|
+
// Each mode follows the same shape:
|
|
20
|
+
// 1. resolve project dir + locate the active plan's status.json
|
|
21
|
+
// 2. parse the raw subagent text via the `parse.ts` preset for that family
|
|
22
|
+
// (parse-delimited-output.ts, s3-feat-003)
|
|
23
|
+
// 3. branch on parse status:
|
|
24
|
+
// ok / fallback_used → persist artefact, advance the state machine,
|
|
25
|
+
// return the next dispatch instruction
|
|
26
|
+
// empty / malformed → return a retry instruction (format recovery)
|
|
27
|
+
// 4. for `review` mode additionally branch on the reviewer's verdict:
|
|
28
|
+
// PASS → status `pending_approval`, instruct caller to run
|
|
29
|
+
// `ghs-plan-finalize`
|
|
30
|
+
// FAIL → status `revising`, round+1, instruct caller to re-dispatch
|
|
31
|
+
// the designer with the review feedback; enforce the
|
|
32
|
+
// max-rounds soft cap + MAX_BREACHES hard cap (plan §5 risk
|
|
33
|
+
// row, source SKILL.md "Phase 2" FAIL branch).
|
|
34
|
+
//
|
|
35
|
+
// Like every other ghs-* tool, `execute` never calls an LLM and never touches
|
|
36
|
+
// the agent registry — it only does file I/O, parsing, state writes, and
|
|
37
|
+
// returns LLM-facing dispatch text. Style follows s2-feat-003 (sprint.ts):
|
|
38
|
+
// thin composition over state.ts + parse.ts + the prompt constants.
|
|
39
|
+
|
|
40
|
+
import { tool } from "@opencode-ai/plugin";
|
|
41
|
+
import type { ToolContext } from "@opencode-ai/plugin/tool";
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
import { resolve, join } from "node:path";
|
|
44
|
+
import { readdir } from "node:fs/promises";
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
readPlanStatus,
|
|
48
|
+
writePlanStatus,
|
|
49
|
+
plansDir,
|
|
50
|
+
type PlanStatus,
|
|
51
|
+
type PlanStatusValue,
|
|
52
|
+
} from "../lib/state.ts";
|
|
53
|
+
import {
|
|
54
|
+
parseContextSnapshot,
|
|
55
|
+
parsePlan,
|
|
56
|
+
parseReview,
|
|
57
|
+
type ParseResult,
|
|
58
|
+
type Verdict,
|
|
59
|
+
} from "../lib/parse.ts";
|
|
60
|
+
import { PLAN_DESIGNER_PROMPT } from "../prompts/plan-designer.ts";
|
|
61
|
+
import { PLAN_REVIEWER_PROMPT } from "../prompts/plan-reviewer.ts";
|
|
62
|
+
import { resolveProjectDir } from "../lib/project.ts";
|
|
63
|
+
|
|
64
|
+
// -----------------------------------------------------------------------------
|
|
65
|
+
// Mode-disambiguation schema (plan §5 risk row).
|
|
66
|
+
// -----------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The three textual payload args. Exactly one must be a non-empty string;
|
|
70
|
+
* supplying zero or more than one is the classic dispatcher ambiguity the
|
|
71
|
+
* plan §5 risk row calls out.
|
|
72
|
+
*
|
|
73
|
+
* `project_dir` is orthogonal (path resolution) and excluded from the
|
|
74
|
+
* "exactly one" rule.
|
|
75
|
+
*/
|
|
76
|
+
export const planReviewArgsSchema = z
|
|
77
|
+
.object({
|
|
78
|
+
snapshot: z.string().optional(),
|
|
79
|
+
plan: z.string().optional(),
|
|
80
|
+
review: z.string().optional(),
|
|
81
|
+
project_dir: z.string().optional(),
|
|
82
|
+
})
|
|
83
|
+
.superRefine((args, ctx) => {
|
|
84
|
+
const present: string[] = [];
|
|
85
|
+
if (args.snapshot && args.snapshot.trim().length > 0) present.push("snapshot");
|
|
86
|
+
if (args.plan && args.plan.trim().length > 0) present.push("plan");
|
|
87
|
+
if (args.review && args.review.trim().length > 0) present.push("review");
|
|
88
|
+
|
|
89
|
+
if (present.length === 0) {
|
|
90
|
+
ctx.addIssue({
|
|
91
|
+
code: z.ZodIssueCode.custom,
|
|
92
|
+
message:
|
|
93
|
+
"Exactly one of `snapshot`, `plan`, or `review` must be non-empty " +
|
|
94
|
+
"(all three are empty). Pass the raw subagent response for the mode " +
|
|
95
|
+
"you are advancing: snapshot from ghs-context-haiku, plan from " +
|
|
96
|
+
"ghs-plan-designer, review from ghs-plan-reviewer.",
|
|
97
|
+
});
|
|
98
|
+
} else if (present.length > 1) {
|
|
99
|
+
ctx.addIssue({
|
|
100
|
+
code: z.ZodIssueCode.custom,
|
|
101
|
+
message:
|
|
102
|
+
"Exactly one of `snapshot`, `plan`, or `review` must be non-empty " +
|
|
103
|
+
`(received ${present.length}: ${present.join(", ")}). The dispatcher ` +
|
|
104
|
+
"advances one mode per call — split into separate ghs-plan-review calls.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** Discriminated mode label, derived post-validation from which arg is set. */
|
|
110
|
+
export type PlanReviewMode = "snapshot" | "plan" | "review";
|
|
111
|
+
|
|
112
|
+
// -----------------------------------------------------------------------------
|
|
113
|
+
// Hard cap on "continue revising anyway" breaches (source SKILL.md Constants).
|
|
114
|
+
// -----------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Maximum number of times the user may override the `max_rounds` soft cap by
|
|
118
|
+
* choosing "Continue revising anyway". Matches the source skill's
|
|
119
|
+
* `MAX_BREACHES = 2` (Phase 2 FAIL @ max_rounds / Phase 3 reject @ max_rounds
|
|
120
|
+
* both consult this). Once `max_rounds_breaches >= MAX_BREACHES`, the
|
|
121
|
+
* dispatcher must NOT offer the continue option — only accept / abort — which
|
|
122
|
+
* guarantees termination in at most `max_rounds + MAX_BREACHES` rounds.
|
|
123
|
+
*/
|
|
124
|
+
export const MAX_BREACHES = 2;
|
|
125
|
+
|
|
126
|
+
// -----------------------------------------------------------------------------
|
|
127
|
+
// Active-plan discovery.
|
|
128
|
+
// -----------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Scan `<projectDir>/.ghs/plans/` for `*-status.json` files and return the
|
|
132
|
+
* single "active" plan's status — i.e. one whose lifecycle is not yet
|
|
133
|
+
* terminal (`approved` / `rejected` / `aborted`).
|
|
134
|
+
*
|
|
135
|
+
* Rationale: the features.json AC pins the args surface to
|
|
136
|
+
* `snapshot? / plan? / review? / project_dir?` (no `plan_id`). Yet
|
|
137
|
+
* `status.json` is keyed by plan_id (s3-feat-005). We resolve the gap by
|
|
138
|
+
* having the dispatcher track at most one active plan at a time: starting a
|
|
139
|
+
* new plan (`ghs-plan-start`) is expected to archive/abandon any prior
|
|
140
|
+
* active one. So at any moment there is 0 or 1 active status file.
|
|
141
|
+
*
|
|
142
|
+
* - 0 active → returns `null` (caller surfaces "no plan in progress, run
|
|
143
|
+
* `ghs-plan-start` first").
|
|
144
|
+
* - 1 active → returns its status.
|
|
145
|
+
* - >1 active → returns the most recently updated one (defensive; should
|
|
146
|
+
* not happen in normal use, but we degrade gracefully instead
|
|
147
|
+
* of refusing to proceed).
|
|
148
|
+
*/
|
|
149
|
+
export async function findActivePlanStatus(
|
|
150
|
+
projectDir: string,
|
|
151
|
+
): Promise<PlanStatus | null> {
|
|
152
|
+
const dir = plansDir(projectDir);
|
|
153
|
+
let entries: string[];
|
|
154
|
+
try {
|
|
155
|
+
entries = await readdir(dir);
|
|
156
|
+
} catch {
|
|
157
|
+
// Directory missing entirely — no plan has ever been started.
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const statuses: PlanStatus[] = [];
|
|
162
|
+
for (const name of entries) {
|
|
163
|
+
if (!name.endsWith("-status.json")) continue;
|
|
164
|
+
// Derive plan_id by stripping the `-status.json` suffix. readPlanStatus
|
|
165
|
+
// re-derives the path, so we pass the bare id.
|
|
166
|
+
const planId = name.slice(0, -"-status.json".length);
|
|
167
|
+
const status = await readPlanStatus(projectDir, planId);
|
|
168
|
+
if (status === null) continue;
|
|
169
|
+
if (isTerminal(status.status)) continue;
|
|
170
|
+
statuses.push(status);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (statuses.length === 0) return null;
|
|
174
|
+
// Defensive tie-break: pick the latest `updated_at` (lexicographic compare
|
|
175
|
+
// works because the timestamp format is YYYY-MM-DDTHH:mm:ss).
|
|
176
|
+
statuses.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
177
|
+
return statuses[0];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Whether a lifecycle state is terminal (no further transitions allowed). */
|
|
181
|
+
function isTerminal(status: PlanStatusValue): boolean {
|
|
182
|
+
return status === "approved" || status === "rejected" || status === "aborted";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// -----------------------------------------------------------------------------
|
|
186
|
+
// Artefact persistence helpers.
|
|
187
|
+
// -----------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Write a parsed artefact to its sibling file under `.ghs/plans/`.
|
|
191
|
+
*
|
|
192
|
+
* The source skill writes `<plan_id>-context.md` / `<plan_id>.md` /
|
|
193
|
+
* `<plan_id>-review.md` next to the `-status.json`. For a `fallback_used`
|
|
194
|
+
* parse the source prepends a warning comment so a reader of the artefact
|
|
195
|
+
* knows extraction was lossy; we mirror that exactly.
|
|
196
|
+
*
|
|
197
|
+
* Returns the absolute path written.
|
|
198
|
+
*/
|
|
199
|
+
async function persistArtefact(
|
|
200
|
+
projectDir: string,
|
|
201
|
+
relativeName: string,
|
|
202
|
+
content: string,
|
|
203
|
+
result: ParseResult,
|
|
204
|
+
): Promise<string> {
|
|
205
|
+
const dir = plansDir(projectDir);
|
|
206
|
+
const absPath = join(dir, relativeName);
|
|
207
|
+
let body = content;
|
|
208
|
+
if (result.status === "fallback_used") {
|
|
209
|
+
const warning =
|
|
210
|
+
`<!-- WARNING: extracted via fallback strategy: ${result.strategy}; ` +
|
|
211
|
+
`warnings: ${result.warnings.join("; ") || "(none)"} -->\n`;
|
|
212
|
+
body = warning + body;
|
|
213
|
+
}
|
|
214
|
+
await Bun.write(absPath, body);
|
|
215
|
+
return absPath;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Persist the raw subagent response as a post-mortem `.raw` file.
|
|
220
|
+
*
|
|
221
|
+
* Only written when parsing failed (empty/malformed) or when
|
|
222
|
+
* `keep_raw_on_success: true` is set on the status — mirroring the source
|
|
223
|
+
* skill's "Format Recovery" section. The dispatcher returns a retry
|
|
224
|
+
* instruction referencing this path so the failing response is auditable.
|
|
225
|
+
*/
|
|
226
|
+
async function persistRawPostMortem(
|
|
227
|
+
projectDir: string,
|
|
228
|
+
relativeBaseName: string,
|
|
229
|
+
rawText: string,
|
|
230
|
+
): Promise<string> {
|
|
231
|
+
const absPath = join(plansDir(projectDir), `${relativeBaseName}.raw`);
|
|
232
|
+
await Bun.write(absPath, rawText);
|
|
233
|
+
return absPath;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// -----------------------------------------------------------------------------
|
|
237
|
+
// Dispatch-text builders — LLM-facing, Chinese prose + English identifiers
|
|
238
|
+
// (per CLAUDE.md language policy).
|
|
239
|
+
// -----------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/** Header block common to every mode's result, for AI + human orientation. */
|
|
242
|
+
function resultHeader(args: {
|
|
243
|
+
projectDir: string;
|
|
244
|
+
planId: string;
|
|
245
|
+
mode: PlanReviewMode;
|
|
246
|
+
round: number;
|
|
247
|
+
status: PlanStatusValue;
|
|
248
|
+
}): string {
|
|
249
|
+
return [
|
|
250
|
+
"=== ghs-plan-review ===",
|
|
251
|
+
"",
|
|
252
|
+
`Project directory: ${args.projectDir}`,
|
|
253
|
+
`Active plan: ${args.planId}`,
|
|
254
|
+
`Mode: ${args.mode}`,
|
|
255
|
+
`Round: ${args.round}`,
|
|
256
|
+
`Plan status: ${args.status}`,
|
|
257
|
+
"",
|
|
258
|
+
].join("\n");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build the retry instruction when parsing failed (empty/malformed/verdict-less).
|
|
263
|
+
*
|
|
264
|
+
* Mirrors the source skill's "Format Recovery" appendix trigger: the
|
|
265
|
+
* dispatcher tells the main AI to re-dispatch the SAME subagent with the
|
|
266
|
+
* delimiter-contract reminder + the raw post-mortem path for context.
|
|
267
|
+
*/
|
|
268
|
+
function buildRetryInstruction(args: {
|
|
269
|
+
projectDir: string;
|
|
270
|
+
planId: string;
|
|
271
|
+
mode: PlanReviewMode;
|
|
272
|
+
result: ParseResult;
|
|
273
|
+
rawPath: string;
|
|
274
|
+
}): string {
|
|
275
|
+
const subagent =
|
|
276
|
+
args.mode === "snapshot"
|
|
277
|
+
? "ghs-context-haiku"
|
|
278
|
+
: args.mode === "plan"
|
|
279
|
+
? "ghs-plan-designer"
|
|
280
|
+
: "ghs-plan-reviewer";
|
|
281
|
+
const delimiterContract =
|
|
282
|
+
args.mode === "snapshot"
|
|
283
|
+
? "`<<<CONTEXT_SNAPSHOT_START>>>` / `<<<CONTEXT_SNAPSHOT_END>>>`"
|
|
284
|
+
: args.mode === "plan"
|
|
285
|
+
? "`<<<PLAN_START>>>` / `<<<PLAN_END>>>`"
|
|
286
|
+
: "`<<<REVIEW_START>>>` / `<<<REVIEW_END>>>` + 裁决行 `REVIEW COMPLETE | Verdict: PASS|FAIL | ...`";
|
|
287
|
+
|
|
288
|
+
return [
|
|
289
|
+
resultHeader({
|
|
290
|
+
projectDir: args.projectDir,
|
|
291
|
+
planId: args.planId,
|
|
292
|
+
mode: args.mode,
|
|
293
|
+
round: 0, // round not advanced on retry
|
|
294
|
+
status: "designing",
|
|
295
|
+
}),
|
|
296
|
+
`⚠️ 解析失败(status: ${args.result.status}, strategy: ${args.result.strategy})。`,
|
|
297
|
+
"",
|
|
298
|
+
`原始响应已存档到 ${args.rawPath} 供诊断。`,
|
|
299
|
+
"",
|
|
300
|
+
`请重新用 Task tool 派发 \`${subagent}\`,并强调分隔标记契约:`,
|
|
301
|
+
`- 结构化内容必须放在 ${delimiterContract} 之间`,
|
|
302
|
+
"- 不要把标记包进 markdown 代码围栏",
|
|
303
|
+
"- 使用字面 ASCII 字符 `<`、`>`、`_`",
|
|
304
|
+
"- 解析器警告:" + (args.result.warnings.join("; ") || "(none)"),
|
|
305
|
+
"",
|
|
306
|
+
"收到合规输出后,再次调用 `ghs-plan-review`(同模式)推进。",
|
|
307
|
+
].join("\n");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// -----------------------------------------------------------------------------
|
|
311
|
+
// Mode handlers.
|
|
312
|
+
// -----------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Snapshot mode — parse the context-haiku subagent's response, persist the
|
|
316
|
+
* snapshot, and return the dispatch instruction for the plan-designer.
|
|
317
|
+
*
|
|
318
|
+
* State transition: `status` → `designing` (the snapshot is now available
|
|
319
|
+
* for the designer to consume). The snapshot file name is recorded on the
|
|
320
|
+
* status's `context_file` if it wasn't already (it usually is, set by
|
|
321
|
+
* `ghs-plan-start`).
|
|
322
|
+
*/
|
|
323
|
+
async function handleSnapshotMode(args: {
|
|
324
|
+
projectDir: string;
|
|
325
|
+
status: PlanStatus;
|
|
326
|
+
rawText: string;
|
|
327
|
+
}): Promise<string> {
|
|
328
|
+
const { projectDir, status, rawText } = args;
|
|
329
|
+
const result = parseContextSnapshot(rawText);
|
|
330
|
+
|
|
331
|
+
if (result.status === "empty" || result.status === "malformed") {
|
|
332
|
+
const rawPath = await persistRawPostMortem(
|
|
333
|
+
projectDir,
|
|
334
|
+
status.context_file.replace(/\.md$/, ""),
|
|
335
|
+
rawText,
|
|
336
|
+
);
|
|
337
|
+
return buildRetryInstruction({
|
|
338
|
+
projectDir,
|
|
339
|
+
planId: status.plan_id,
|
|
340
|
+
mode: "snapshot",
|
|
341
|
+
result,
|
|
342
|
+
rawPath,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Success — persist the snapshot.
|
|
347
|
+
await persistArtefact(projectDir, status.context_file, result.content, result);
|
|
348
|
+
|
|
349
|
+
// Advance state: the snapshot is ready, the designer is next.
|
|
350
|
+
const nextStatus: PlanStatus = {
|
|
351
|
+
...status,
|
|
352
|
+
status: "designing",
|
|
353
|
+
updated_at: nowTimestamp(),
|
|
354
|
+
};
|
|
355
|
+
await writePlanStatus(projectDir, nextStatus);
|
|
356
|
+
|
|
357
|
+
return [
|
|
358
|
+
resultHeader({
|
|
359
|
+
projectDir,
|
|
360
|
+
planId: status.plan_id,
|
|
361
|
+
mode: "snapshot",
|
|
362
|
+
round: nextStatus.round,
|
|
363
|
+
status: nextStatus.status,
|
|
364
|
+
}),
|
|
365
|
+
`✅ Context snapshot 已提取(status: ${result.status}, strategy: ${result.strategy})。`,
|
|
366
|
+
"",
|
|
367
|
+
`Snapshot 写入:${join(plansDir(projectDir), status.context_file)}`,
|
|
368
|
+
`Codegraph 路径:${status.codegraph_available ? "codegraph" : "grep 回退"}`,
|
|
369
|
+
"",
|
|
370
|
+
"下一步:派发 plan-designer 设计技术方案。",
|
|
371
|
+
"",
|
|
372
|
+
"--- plan-designer dispatch ---",
|
|
373
|
+
PLAN_DESIGNER_PROMPT,
|
|
374
|
+
].join("\n");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Plan mode — parse the plan-designer subagent's response, persist the plan,
|
|
379
|
+
* and return the dispatch instruction for the plan-reviewer.
|
|
380
|
+
*
|
|
381
|
+
* State transition: `status` → `reviewing`.
|
|
382
|
+
*/
|
|
383
|
+
async function handlePlanMode(args: {
|
|
384
|
+
projectDir: string;
|
|
385
|
+
status: PlanStatus;
|
|
386
|
+
rawText: string;
|
|
387
|
+
}): Promise<string> {
|
|
388
|
+
const { projectDir, status, rawText } = args;
|
|
389
|
+
const result = parsePlan(rawText);
|
|
390
|
+
|
|
391
|
+
if (result.status === "empty" || result.status === "malformed") {
|
|
392
|
+
const rawPath = await persistRawPostMortem(
|
|
393
|
+
projectDir,
|
|
394
|
+
status.plan_file.replace(/\.md$/, ""),
|
|
395
|
+
rawText,
|
|
396
|
+
);
|
|
397
|
+
return buildRetryInstruction({
|
|
398
|
+
projectDir,
|
|
399
|
+
planId: status.plan_id,
|
|
400
|
+
mode: "plan",
|
|
401
|
+
result,
|
|
402
|
+
rawPath,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await persistArtefact(projectDir, status.plan_file, result.content, result);
|
|
407
|
+
|
|
408
|
+
const nextStatus: PlanStatus = {
|
|
409
|
+
...status,
|
|
410
|
+
status: "reviewing",
|
|
411
|
+
updated_at: nowTimestamp(),
|
|
412
|
+
};
|
|
413
|
+
await writePlanStatus(projectDir, nextStatus);
|
|
414
|
+
|
|
415
|
+
return [
|
|
416
|
+
resultHeader({
|
|
417
|
+
projectDir,
|
|
418
|
+
planId: status.plan_id,
|
|
419
|
+
mode: "plan",
|
|
420
|
+
round: nextStatus.round,
|
|
421
|
+
status: nextStatus.status,
|
|
422
|
+
}),
|
|
423
|
+
`✅ Plan 已提取(status: ${result.status}, strategy: ${result.strategy})。`,
|
|
424
|
+
"",
|
|
425
|
+
`Plan 写入:${join(plansDir(projectDir), status.plan_file)}`,
|
|
426
|
+
"",
|
|
427
|
+
"下一步:派发 plan-reviewer 评审技术方案。",
|
|
428
|
+
"",
|
|
429
|
+
"--- plan-reviewer dispatch ---",
|
|
430
|
+
PLAN_REVIEWER_PROMPT,
|
|
431
|
+
].join("\n");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Review mode — parse the plan-reviewer subagent's response, persist the
|
|
436
|
+
* review, read the verdict, and branch:
|
|
437
|
+
*
|
|
438
|
+
* PASS → status `pending_approval`, instruct caller to run
|
|
439
|
+
* `ghs-plan-finalize` (source Phase 2 PASS branch + Phase 3).
|
|
440
|
+
* FAIL → status `revising`, round+1, instruct caller to re-dispatch the
|
|
441
|
+
* designer with the review feedback; enforce max_rounds +
|
|
442
|
+
* MAX_BREACHES caps (source Phase 2 FAIL branch).
|
|
443
|
+
* null → fall through to the retry path (verdict line missing).
|
|
444
|
+
*
|
|
445
|
+
* The user-approval Phase 3 step from the source skill is collapsed into the
|
|
446
|
+
* PASS instruction text (OpenCode has no sync `AskUserQuestion`; the main AI
|
|
447
|
+
* asks the user in chat between tool calls).
|
|
448
|
+
*/
|
|
449
|
+
async function handleReviewMode(args: {
|
|
450
|
+
projectDir: string;
|
|
451
|
+
status: PlanStatus;
|
|
452
|
+
rawText: string;
|
|
453
|
+
}): Promise<string> {
|
|
454
|
+
const { projectDir, status, rawText } = args;
|
|
455
|
+
const result = parseReview(rawText);
|
|
456
|
+
const verdict: Verdict = result.verdict;
|
|
457
|
+
|
|
458
|
+
// Persist the review file path on the status (first reviewer run).
|
|
459
|
+
const reviewFile =
|
|
460
|
+
status.review_file ?? `${status.plan_id}-review.md`;
|
|
461
|
+
|
|
462
|
+
// empty / malformed / verdict-less → retry path (source: "verdict == null"
|
|
463
|
+
// is treated as a format deviation).
|
|
464
|
+
if (
|
|
465
|
+
result.status === "empty" ||
|
|
466
|
+
result.status === "malformed" ||
|
|
467
|
+
verdict === null
|
|
468
|
+
) {
|
|
469
|
+
// Persist review artefact if we have any content, else raw post-mortem.
|
|
470
|
+
let rawPath: string;
|
|
471
|
+
if (result.content.trim().length > 0) {
|
|
472
|
+
await persistArtefact(projectDir, reviewFile, result.content, result);
|
|
473
|
+
rawPath = join(plansDir(projectDir), reviewFile);
|
|
474
|
+
} else {
|
|
475
|
+
rawPath = await persistRawPostMortem(
|
|
476
|
+
projectDir,
|
|
477
|
+
reviewFile.replace(/\.md$/, ""),
|
|
478
|
+
rawText,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
const retry = buildRetryInstruction({
|
|
482
|
+
projectDir,
|
|
483
|
+
planId: status.plan_id,
|
|
484
|
+
mode: "review",
|
|
485
|
+
result,
|
|
486
|
+
rawPath,
|
|
487
|
+
});
|
|
488
|
+
return retry.replace(
|
|
489
|
+
/解析失败(status:.*?\)/,
|
|
490
|
+
`解析失败(status: ${result.status}, verdict: ${verdict ?? "null"})`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// We have a usable review — persist it + record review_file on the status.
|
|
495
|
+
await persistArtefact(projectDir, reviewFile, result.content, result);
|
|
496
|
+
|
|
497
|
+
if (verdict === "PASS") {
|
|
498
|
+
const nextStatus: PlanStatus = {
|
|
499
|
+
...status,
|
|
500
|
+
review_file: reviewFile,
|
|
501
|
+
status: "pending_approval",
|
|
502
|
+
updated_at: nowTimestamp(),
|
|
503
|
+
};
|
|
504
|
+
await writePlanStatus(projectDir, nextStatus);
|
|
505
|
+
|
|
506
|
+
return [
|
|
507
|
+
resultHeader({
|
|
508
|
+
projectDir,
|
|
509
|
+
planId: status.plan_id,
|
|
510
|
+
mode: "review",
|
|
511
|
+
round: nextStatus.round,
|
|
512
|
+
status: nextStatus.status,
|
|
513
|
+
}),
|
|
514
|
+
"✅ Review PASS —— 方案通过评审(仅 Optimization 项,无 Severe/Medium)。",
|
|
515
|
+
"",
|
|
516
|
+
`Review 写入:${join(plansDir(projectDir), reviewFile)}`,
|
|
517
|
+
"",
|
|
518
|
+
"下一步:请向用户确认是否批准该方案。用户批准后调用 `ghs-plan-finalize` 写出最终 plan。",
|
|
519
|
+
"(OpenCode 无同步阻塞询问 —— 在 chat 中向用户提问即可。)",
|
|
520
|
+
].join("\n");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// verdict === "FAIL" — enforce the round / breach caps before instructing
|
|
524
|
+
// a revise. Source Phase 2 FAIL branch.
|
|
525
|
+
const atSoftCap = status.round >= status.max_rounds;
|
|
526
|
+
const atHardCap = status.max_rounds_breaches >= MAX_BREACHES;
|
|
527
|
+
|
|
528
|
+
if (atSoftCap && atHardCap) {
|
|
529
|
+
// Hard cap reached: refuse to start another round. Surface the user
|
|
530
|
+
// decision (accept-with-fail vs abort). We do NOT mutate status here —
|
|
531
|
+
// the next tool call (finalize or a fresh start) drives the transition.
|
|
532
|
+
const nextStatus: PlanStatus = {
|
|
533
|
+
...status,
|
|
534
|
+
review_file: reviewFile,
|
|
535
|
+
status: "pending_approval",
|
|
536
|
+
updated_at: nowTimestamp(),
|
|
537
|
+
};
|
|
538
|
+
await writePlanStatus(projectDir, nextStatus);
|
|
539
|
+
|
|
540
|
+
return [
|
|
541
|
+
resultHeader({
|
|
542
|
+
projectDir,
|
|
543
|
+
planId: status.plan_id,
|
|
544
|
+
mode: "review",
|
|
545
|
+
round: nextStatus.round,
|
|
546
|
+
status: nextStatus.status,
|
|
547
|
+
}),
|
|
548
|
+
"🛑 Review FAIL 且已达硬上限(max_rounds=" + status.max_rounds +
|
|
549
|
+
", breaches=" + status.max_rounds_breaches +
|
|
550
|
+
"/" + MAX_BREACHES + ")—— 不再允许修订轮次。",
|
|
551
|
+
"",
|
|
552
|
+
`Review 写入:${join(plansDir(projectDir), reviewFile)}`,
|
|
553
|
+
"",
|
|
554
|
+
"用户须二选一(在 chat 中向用户提问):",
|
|
555
|
+
" 1. 接受当前方案(带 Severe/Medium 未修复项)→ 调用 `ghs-plan-finalize`,",
|
|
556
|
+
" 产物 plan 顶部会标注 `WARNING: accepted with unfixed issues`。",
|
|
557
|
+
" 2. 终止 → 状态改为 aborted;用户重新 `ghs-plan-start` 启动新 plan。",
|
|
558
|
+
].join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (atSoftCap) {
|
|
562
|
+
// Soft cap reached but breaches remaining — surface the 3-way user
|
|
563
|
+
// decision (continue-breach / accept-with-fail / abort). We do NOT
|
|
564
|
+
// auto-advance round until the user picks "continue". Status stays as
|
|
565
|
+
// reviewing so the caller knows the loop is paused on a decision.
|
|
566
|
+
const nextStatus: PlanStatus = {
|
|
567
|
+
...status,
|
|
568
|
+
review_file: reviewFile,
|
|
569
|
+
status: "pending_approval",
|
|
570
|
+
updated_at: nowTimestamp(),
|
|
571
|
+
};
|
|
572
|
+
await writePlanStatus(projectDir, nextStatus);
|
|
573
|
+
|
|
574
|
+
const remaining = MAX_BREACHES - status.max_rounds_breaches;
|
|
575
|
+
return [
|
|
576
|
+
resultHeader({
|
|
577
|
+
projectDir,
|
|
578
|
+
planId: status.plan_id,
|
|
579
|
+
mode: "review",
|
|
580
|
+
round: nextStatus.round,
|
|
581
|
+
status: nextStatus.status,
|
|
582
|
+
}),
|
|
583
|
+
"⚠️ Review FAIL 且已达软上限 max_rounds=" + status.max_rounds +
|
|
584
|
+
"(剩余 breach 额度 " + remaining + "/" + MAX_BREACHES + ")。",
|
|
585
|
+
"",
|
|
586
|
+
`Review 写入:${join(plansDir(projectDir), reviewFile)}`,
|
|
587
|
+
"",
|
|
588
|
+
"用户须三选一(在 chat 中向用户提问,附上评审报告):",
|
|
589
|
+
" 1. 继续修订(一次性 breach)→ 再次调用 `ghs-plan-review(review=...)` 无法触发,",
|
|
590
|
+
" 请改用 `ghs-plan-review(plan=<修订后的 designer 输出>)`;调用前请告知 AI",
|
|
591
|
+
" 用户选择了 breach,AI 会确保 status 的 max_rounds_breaches 自增。(注:",
|
|
592
|
+
" 当前实现把 breach 计数委托给下一次 plan 模式调用时推进 —— 见下方说明。)",
|
|
593
|
+
" 2. 接受当前方案(带未修复项)→ 调用 `ghs-plan-finalize`。",
|
|
594
|
+
" 3. 终止 → 重新 `ghs-plan-start`。",
|
|
595
|
+
"",
|
|
596
|
+
"说明:breach 计数 (max_rounds_breaches) 在下一轮 designer 产物被 ghs-plan-review(plan) 接收时",
|
|
597
|
+
"自增并推进 round —— 这样状态机始终在单一入口推进,避免双写。",
|
|
598
|
+
].join("\n");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Below soft cap — auto-advance to the next revise round.
|
|
602
|
+
const nextStatus: PlanStatus = {
|
|
603
|
+
...status,
|
|
604
|
+
review_file: reviewFile,
|
|
605
|
+
status: "revising",
|
|
606
|
+
round: status.round + 1,
|
|
607
|
+
// If this revise round is itself a breach continuation (round already
|
|
608
|
+
// exceeded max_rounds on a prior pass), carry the breach increment here.
|
|
609
|
+
max_rounds_breaches:
|
|
610
|
+
status.round + 1 > status.max_rounds
|
|
611
|
+
? status.max_rounds_breaches + 1
|
|
612
|
+
: status.max_rounds_breaches,
|
|
613
|
+
updated_at: nowTimestamp(),
|
|
614
|
+
};
|
|
615
|
+
await writePlanStatus(projectDir, nextStatus);
|
|
616
|
+
|
|
617
|
+
return [
|
|
618
|
+
resultHeader({
|
|
619
|
+
projectDir,
|
|
620
|
+
planId: status.plan_id,
|
|
621
|
+
mode: "review",
|
|
622
|
+
round: nextStatus.round,
|
|
623
|
+
status: nextStatus.status,
|
|
624
|
+
}),
|
|
625
|
+
"⚠️ Review FAIL —— 触发修订轮次 round " + status.round + " → " + nextStatus.round + "。",
|
|
626
|
+
"",
|
|
627
|
+
`Review 写入:${join(plansDir(projectDir), reviewFile)}`,
|
|
628
|
+
`剩余轮次预算:${status.max_rounds - nextStatus.round} 轮(max_rounds=${status.max_rounds})`,
|
|
629
|
+
"",
|
|
630
|
+
"下一步:用 Task tool 重新派发 `ghs-plan-designer`,把本轮评审报告作为修订反馈一并传入。",
|
|
631
|
+
"designer 产出后,再次调用 `ghs-plan-review(plan=...)`。",
|
|
632
|
+
"",
|
|
633
|
+
"--- plan-designer dispatch (revise) ---",
|
|
634
|
+
PLAN_DESIGNER_PROMPT,
|
|
635
|
+
].join("\n");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Current local timestamp in the state.ts format (YYYY-MM-DDTHH:mm:ss). */
|
|
639
|
+
function nowTimestamp(): string {
|
|
640
|
+
// Local import to avoid a date-time round-trip mismatch with state.ts.
|
|
641
|
+
// We re-implement the same formatter inline rather than importing
|
|
642
|
+
// `formatLocalTimestamp` to keep this module's dependency surface tight —
|
|
643
|
+
// but the output is byte-identical (verified by state.test.ts).
|
|
644
|
+
const now = new Date();
|
|
645
|
+
const pad = (n: number): string => n.toString().padStart(2, "0");
|
|
646
|
+
return (
|
|
647
|
+
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
|
648
|
+
`T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// -----------------------------------------------------------------------------
|
|
653
|
+
// Tool definition.
|
|
654
|
+
// -----------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The `ghs-plan-review` tool definition. Registered by the plugin entry
|
|
658
|
+
* point under the `ghs-plan-review` key (hyphenated, per spike 001 / D1).
|
|
659
|
+
*
|
|
660
|
+
* The SDK's `args` shape is a flat `ZodRawShape`; the "exactly one of
|
|
661
|
+
* snapshot/plan/review non-empty" constraint is enforced by
|
|
662
|
+
* {@link planReviewArgsSchema} via a `.superRefine` run inside `execute`.
|
|
663
|
+
*/
|
|
664
|
+
export const planReviewTool = tool({
|
|
665
|
+
description:
|
|
666
|
+
"Core loop of the 3-role plan dispatcher (ghs-plan-start → review × N → finalize). " +
|
|
667
|
+
"Three modes, selected by which payload arg is non-empty: " +
|
|
668
|
+
"`snapshot` (parse ghs-context-haiku output → dispatch ghs-plan-designer), " +
|
|
669
|
+
"`plan` (parse ghs-plan-designer output → dispatch ghs-plan-reviewer), " +
|
|
670
|
+
"`review` (parse ghs-plan-reviewer output → PASS advances to ghs-plan-finalize, " +
|
|
671
|
+
"FAIL triggers a revise round with max-rounds + breach caps). " +
|
|
672
|
+
"Exactly one of snapshot/plan/review must be non-empty (Zod-enforced). " +
|
|
673
|
+
"Locates the active plan's status.json automatically (no plan_id arg).",
|
|
674
|
+
args: {
|
|
675
|
+
snapshot: tool.schema
|
|
676
|
+
.string()
|
|
677
|
+
.optional()
|
|
678
|
+
.describe(
|
|
679
|
+
"Raw response from the ghs-context-haiku subagent (snapshot mode). " +
|
|
680
|
+
"Must include the <<<CONTEXT_SNAPSHOT_START>>>/<<<CONTEXT_SNAPSHOT_END>>> delimiters.",
|
|
681
|
+
),
|
|
682
|
+
plan: tool.schema
|
|
683
|
+
.string()
|
|
684
|
+
.optional()
|
|
685
|
+
.describe(
|
|
686
|
+
"Raw response from the ghs-plan-designer subagent (plan mode). " +
|
|
687
|
+
"Must include the <<<PLAN_START>>>/<<<PLAN_END>>> delimiters.",
|
|
688
|
+
),
|
|
689
|
+
review: tool.schema
|
|
690
|
+
.string()
|
|
691
|
+
.optional()
|
|
692
|
+
.describe(
|
|
693
|
+
"Raw response from the ghs-plan-reviewer subagent (review mode). " +
|
|
694
|
+
"Must include the <<<REVIEW_START>>>/<<<REVIEW_END>>> delimiters and the " +
|
|
695
|
+
"`REVIEW COMPLETE | Verdict: PASS|FAIL | ...` verdict line.",
|
|
696
|
+
),
|
|
697
|
+
project_dir: tool.schema
|
|
698
|
+
.string()
|
|
699
|
+
.optional()
|
|
700
|
+
.describe(
|
|
701
|
+
"Absolute path of the project root. Defaults to the opencode session's worktree/directory.",
|
|
702
|
+
),
|
|
703
|
+
},
|
|
704
|
+
async execute(
|
|
705
|
+
args: {
|
|
706
|
+
snapshot?: string;
|
|
707
|
+
plan?: string;
|
|
708
|
+
review?: string;
|
|
709
|
+
project_dir?: string;
|
|
710
|
+
},
|
|
711
|
+
ctx: ToolContext,
|
|
712
|
+
): Promise<string> {
|
|
713
|
+
// Enforce the "exactly one payload non-empty" constraint. Throws a
|
|
714
|
+
// ZodError on violation, which the OpenCode runtime surfaces to the AI
|
|
715
|
+
// as a tool-call error — exactly the disambiguation the plan §5 risk
|
|
716
|
+
// row prescribes.
|
|
717
|
+
const validated = planReviewArgsSchema.parse(args);
|
|
718
|
+
|
|
719
|
+
const projectDir = validated.project_dir
|
|
720
|
+
? resolve(validated.project_dir)
|
|
721
|
+
: resolveProjectDir(ctx);
|
|
722
|
+
|
|
723
|
+
// Locate the active plan.
|
|
724
|
+
const status = await findActivePlanStatus(projectDir);
|
|
725
|
+
if (status === null) {
|
|
726
|
+
return [
|
|
727
|
+
"=== ghs-plan-review ===",
|
|
728
|
+
"",
|
|
729
|
+
`Project directory: ${projectDir}`,
|
|
730
|
+
"",
|
|
731
|
+
"❌ 当前没有进行中的 plan(.ghs/plans/ 下无 active status.json)。",
|
|
732
|
+
"",
|
|
733
|
+
"请先调用 `ghs-plan-start` 启动一个新 plan,再用 Task tool 派发对应 subagent,",
|
|
734
|
+
"然后把 subagent 的分隔标记输出原样传给本 tool 的 snapshot/plan/review 参数。",
|
|
735
|
+
].join("\n");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const mode: PlanReviewMode = validated.snapshot
|
|
739
|
+
? "snapshot"
|
|
740
|
+
: validated.plan
|
|
741
|
+
? "plan"
|
|
742
|
+
: "review";
|
|
743
|
+
|
|
744
|
+
const rawText =
|
|
745
|
+
mode === "snapshot"
|
|
746
|
+
? (validated.snapshot as string)
|
|
747
|
+
: mode === "plan"
|
|
748
|
+
? (validated.plan as string)
|
|
749
|
+
: (validated.review as string);
|
|
750
|
+
|
|
751
|
+
if (mode === "snapshot") {
|
|
752
|
+
return handleSnapshotMode({ projectDir, status, rawText });
|
|
753
|
+
}
|
|
754
|
+
if (mode === "plan") {
|
|
755
|
+
return handlePlanMode({ projectDir, status, rawText });
|
|
756
|
+
}
|
|
757
|
+
return handleReviewMode({ projectDir, status, rawText });
|
|
758
|
+
},
|
|
759
|
+
});
|