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.
Files changed (55) hide show
  1. package/README.md +184 -0
  2. package/package.json +51 -0
  3. package/shared/SPIKE_RESULTS.md +597 -0
  4. package/shared/agents/ghs-context-haiku.md.template +124 -0
  5. package/shared/agents/ghs-plan-designer.md.template +128 -0
  6. package/shared/agents/ghs-plan-reviewer.md.template +170 -0
  7. package/shared/assets/features.json +67 -0
  8. package/shared/assets/progress.md +35 -0
  9. package/shared/ghs.default.json +7 -0
  10. package/shared/ghs.default.json.notes.md +34 -0
  11. package/shared/ghs.json.example +7 -0
  12. package/shared/opencode.json.example +11 -0
  13. package/shared/references/coding-agent.md +533 -0
  14. package/shared/references/context-snapshot-guide.md +98 -0
  15. package/shared/references/examples.md +299 -0
  16. package/shared/references/plan-designer.md +163 -0
  17. package/shared/references/plan-reviewer.md +193 -0
  18. package/shared/references/sprint-agent.md +261 -0
  19. package/src/index.ts +9 -0
  20. package/src/lib/assets.ts +31 -0
  21. package/src/lib/codegraph.ts +66 -0
  22. package/src/lib/config.ts +278 -0
  23. package/src/lib/nonce.ts +56 -0
  24. package/src/lib/parse.ts +175 -0
  25. package/src/lib/paths.ts +26 -0
  26. package/src/lib/project.ts +28 -0
  27. package/src/lib/scripts/append-progress-session.ts +178 -0
  28. package/src/lib/scripts/append-sprint.ts +121 -0
  29. package/src/lib/scripts/archive-sprint.ts +583 -0
  30. package/src/lib/scripts/init-project.ts +291 -0
  31. package/src/lib/scripts/parallel-utils.ts +380 -0
  32. package/src/lib/scripts/parse-completion-signal.ts +584 -0
  33. package/src/lib/scripts/parse-delimited-output.ts +632 -0
  34. package/src/lib/scripts/resolve-project-dir.ts +130 -0
  35. package/src/lib/scripts/status.ts +292 -0
  36. package/src/lib/scripts/update-feature-status.ts +169 -0
  37. package/src/lib/scripts/validate-structure.ts +290 -0
  38. package/src/lib/state.ts +305 -0
  39. package/src/plugin.ts +76 -0
  40. package/src/prompts/context-codegraph.ts +65 -0
  41. package/src/prompts/context-grep.ts +68 -0
  42. package/src/prompts/feature-impl.ts +78 -0
  43. package/src/prompts/plan-designer.ts +59 -0
  44. package/src/prompts/plan-reviewer.ts +61 -0
  45. package/src/prompts/sprint-planning.ts +47 -0
  46. package/src/tools/archive.ts +278 -0
  47. package/src/tools/code.ts +448 -0
  48. package/src/tools/config.ts +182 -0
  49. package/src/tools/force-archive.ts +195 -0
  50. package/src/tools/init.ts +193 -0
  51. package/src/tools/plan-finalize.ts +333 -0
  52. package/src/tools/plan-review.ts +759 -0
  53. package/src/tools/plan-start.ts +232 -0
  54. package/src/tools/sprint.ts +213 -0
  55. 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
+ });