ultimate-pi 0.13.0 → 0.14.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 (39) hide show
  1. package/{.pi → .agents}/skills/ccc/SKILL.md +1 -7
  2. package/.agents/skills/ccc/references/settings.md +126 -0
  3. package/.agents/skills/harness-debate-plan/SKILL.md +61 -21
  4. package/.agents/skills/harness-orchestration/SKILL.md +1 -1
  5. package/.pi/agents/harness/planning/plan-adversary.md +2 -2
  6. package/.pi/agents/harness/planning/plan-evaluator.md +3 -1
  7. package/.pi/agents/harness/planning/review-integrator.md +4 -2
  8. package/.pi/extensions/debate-orchestrator.ts +39 -435
  9. package/.pi/extensions/harness-debate-tools.ts +519 -0
  10. package/.pi/extensions/harness-plan-approval.ts +41 -17
  11. package/.pi/extensions/harness-run-context.ts +18 -0
  12. package/.pi/extensions/lib/debate-bus-core.ts +434 -0
  13. package/.pi/extensions/lib/debate-bus-state.ts +58 -0
  14. package/.pi/extensions/lib/harness-spawn-budget.ts +5 -25
  15. package/.pi/extensions/lib/plan-approval/dialog.ts +33 -272
  16. package/.pi/extensions/lib/plan-approval/format-plan.ts +12 -85
  17. package/.pi/extensions/lib/plan-approval/plan-review.ts +6 -6
  18. package/.pi/extensions/lib/plan-approval/render.ts +6 -0
  19. package/.pi/extensions/lib/plan-approval/validate.ts +1 -1
  20. package/.pi/extensions/lib/plan-debate-envelope.ts +2 -0
  21. package/.pi/extensions/lib/plan-debate-gate.ts +155 -0
  22. package/.pi/extensions/lib/plan-debate-id.ts +39 -0
  23. package/.pi/extensions/lib/plan-debate-lane.ts +220 -0
  24. package/.pi/extensions/lib/plan-debate-round-status.ts +94 -0
  25. package/.pi/extensions/lib/plan-debate-write-guard.ts +20 -0
  26. package/.pi/extensions/lib/plan-messenger.ts +276 -0
  27. package/.pi/extensions/lib/plan-review-integrator-rules.ts +119 -0
  28. package/.pi/extensions/lib/plan-scope-guard.ts +89 -0
  29. package/.pi/harness/agents.manifest.json +7 -7
  30. package/.pi/prompts/harness-plan.md +22 -12
  31. package/CHANGELOG.md +18 -0
  32. package/THIRD_PARTY_NOTICES.md +1 -1
  33. package/package.json +3 -3
  34. package/.agents/skills/ck-search/SKILL.md +0 -23
  35. package/.agents/skills/cocoindex-search/SKILL.md +0 -35
  36. package/.agents/skills/obsidian-bases/SKILL.md +0 -299
  37. package/.agents/skills/obsidian-markdown/SKILL.md +0 -237
  38. package/.pi/extensions/lib/plan-approval/fallback.ts +0 -50
  39. /package/{.pi → .agents}/skills/ccc/references/management.md +0 -0
@@ -0,0 +1,519 @@
1
+ /**
2
+ * P0–P3 plan debate tools — bus + pi-messenger transport.
3
+ */
4
+
5
+ import { mkdir } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "@sinclair/typebox";
9
+ import type { DebateParticipant } from "../lib/debate-orchestrator-types.js";
10
+ import {
11
+ getLatestRunContext,
12
+ getRunIdFromSession,
13
+ } from "../lib/harness-run-context.js";
14
+ import { writeYamlFile } from "../lib/harness-yaml.js";
15
+ import {
16
+ acceptDebateRound,
17
+ finalizeDebateConsensus,
18
+ openDebateBus,
19
+ } from "./lib/debate-bus-core.js";
20
+ import { getDebateState } from "./lib/debate-bus-state.js";
21
+ import { claimExtensionLoad } from "./lib/extension-load-guard.js";
22
+ import { captureHarnessEvent } from "./lib/harness-posthog.js";
23
+ import {
24
+ buildPlanReviewRoundEnvelope,
25
+ type PlanReviewRoundDraft,
26
+ } from "./lib/plan-debate-envelope.js";
27
+ import {
28
+ normalizePlanDebateId,
29
+ planDebateIdForRun,
30
+ } from "./lib/plan-debate-id.js";
31
+ import {
32
+ applyDebateLane,
33
+ type DebateLaneKind,
34
+ debateLaneForAgent,
35
+ formatApplyLaneMessage,
36
+ } from "./lib/plan-debate-lane.js";
37
+ import { getPlanDebateRoundStatus } from "./lib/plan-debate-round-status.js";
38
+ import { withReviewRoundYamlWrite } from "./lib/plan-debate-write-guard.js";
39
+ import {
40
+ formatTranscriptForSpawn,
41
+ getMessengerRoundState,
42
+ initPlanMessenger,
43
+ messengerRoundDebateReady,
44
+ postMessengerMessage,
45
+ readRoundTranscript,
46
+ } from "./lib/plan-messenger.js";
47
+ import {
48
+ loadValidationTurnYaml,
49
+ validateIntegratorDraft,
50
+ } from "./lib/plan-review-integrator-rules.js";
51
+ import { assessPlanScopeDrift } from "./lib/plan-scope-guard.js";
52
+
53
+ // @ts-expect-error pi extensions run as ESM
54
+ const MODULE_URL = import.meta.url;
55
+
56
+ function getRunId(ctx: {
57
+ sessionManager: { getEntries(): unknown[]; getSessionId(): string };
58
+ }): string {
59
+ return (
60
+ getRunIdFromSession(
61
+ ctx.sessionManager.getEntries(),
62
+ ctx.sessionManager.getSessionId(),
63
+ ) ?? ctx.sessionManager.getSessionId()
64
+ );
65
+ }
66
+
67
+ function runDir(projectRoot: string, runId: string): string {
68
+ return join(projectRoot, ".pi", "harness", "runs", runId);
69
+ }
70
+
71
+ function debateHooks(pi: ExtensionAPI) {
72
+ return {
73
+ appendEntry: (customType: string, data: unknown) =>
74
+ pi.appendEntry(customType, data),
75
+ };
76
+ }
77
+
78
+ function telemetryRound(
79
+ sessionId: string,
80
+ props: Record<string, unknown>,
81
+ ): void {
82
+ captureHarnessEvent(sessionId, "harness_debate_round", props);
83
+ }
84
+
85
+ function subagentResults(
86
+ details: unknown,
87
+ ): Array<{ agent: string; finalOutput?: string }> {
88
+ const d = details as {
89
+ results?: Array<{ agent: string; finalOutput?: string }>;
90
+ };
91
+ return d?.results ?? [];
92
+ }
93
+
94
+ export default function harnessDebateTools(pi: ExtensionAPI) {
95
+ if (!claimExtensionLoad("harness-debate-tools", MODULE_URL)) return;
96
+
97
+ pi.on("tool_result", async (event, ctx) => {
98
+ if (event.isError || event.toolName !== "subagent") return;
99
+ const runId = getRunId(ctx);
100
+ const projectRoot = process.cwd();
101
+ const rd = runDir(projectRoot, runId);
102
+ const entries = ctx.sessionManager.getEntries();
103
+ const runCtx = getLatestRunContext(entries);
104
+ if (!runCtx?.run_id || runCtx.run_id !== runId) return;
105
+
106
+ const applied: string[] = [];
107
+ let lastRound = 1;
108
+ for (const result of subagentResults(event.details)) {
109
+ const lane = debateLaneForAgent(result.agent ?? "");
110
+ if (!lane || !result.finalOutput?.trim()) continue;
111
+ const out = await applyDebateLane({
112
+ runDir: rd,
113
+ lane,
114
+ content: result.finalOutput,
115
+ });
116
+ if (out.round_index) lastRound = out.round_index;
117
+ pi.appendEntry("harness-debate-lane-applied", {
118
+ agent: result.agent,
119
+ ...out,
120
+ });
121
+ applied.push(formatApplyLaneMessage(out));
122
+ }
123
+ if (applied.length === 0) return;
124
+
125
+ const status = await getPlanDebateRoundStatus(rd, lastRound);
126
+ pi.sendMessage({
127
+ customType: "harness-debate-next-step",
128
+ content: [
129
+ "**Debate lane auto-applied from subagent output**",
130
+ ...applied,
131
+ "",
132
+ status.next_tool
133
+ ? `**Required next tool (do not stop with prose only):** ${status.next_tool}`
134
+ : "Check harness_debate_round_status for this round.",
135
+ ].join("\n"),
136
+ display: true,
137
+ details: { applied, status },
138
+ });
139
+ });
140
+
141
+ pi.registerTool({
142
+ name: "harness_debate_open",
143
+ label: "Open Plan Debate",
144
+ description:
145
+ "Open plan-phase debate bus (plan-<run_id>) and initialize pi-messenger inboxes/threads. Call once before Review Gate rounds.",
146
+ parameters: Type.Object({
147
+ debate_id: Type.Optional(
148
+ Type.String({ description: "Optional; normalized to plan-<run_id>" }),
149
+ ),
150
+ }),
151
+ async execute(_id, params, _signal, _onUpdate, ctx) {
152
+ const runId = getRunId(ctx);
153
+ const projectRoot = process.cwd();
154
+ const raw = String((params as { debate_id?: string }).debate_id ?? "");
155
+ const { debateId, corrected, warning } = normalizePlanDebateId(
156
+ raw,
157
+ runId,
158
+ );
159
+ const opened = await openDebateBus(runId, debateId, debateHooks(pi));
160
+ await initPlanMessenger(runDir(projectRoot, runId), {
161
+ runId,
162
+ debateId,
163
+ });
164
+ const sessionId = ctx.sessionManager.getSessionId();
165
+ captureHarnessEvent(sessionId, "harness_debate_round", {
166
+ run_id: runId,
167
+ debate_id: debateId,
168
+ event: "open",
169
+ debate_phase: "plan",
170
+ corrected_id: corrected,
171
+ });
172
+ const lines = [
173
+ `Plan debate opened: ${debateId}`,
174
+ `Messenger: debate-messenger/ (inbox + threads/round-N/transcript.jsonl)`,
175
+ ];
176
+ if (warning) lines.push(`Note: ${warning}`);
177
+ return {
178
+ content: [{ type: "text", text: lines.join("\n") }],
179
+ details: { run_id: runId, debate_id: debateId, state: opened },
180
+ };
181
+ },
182
+ });
183
+
184
+ pi.registerTool({
185
+ name: "harness_messenger_post",
186
+ label: "Post Debate Messenger Message",
187
+ description:
188
+ "Post a claim/rebuttal/integrate message to the round thread and agent inbox (pi-messenger style). Evaluator posts claims first; adversary rebuts with in_reply_to claim ids.",
189
+ parameters: Type.Object({
190
+ round_index: Type.Number({ description: "1–4" }),
191
+ from: Type.String({
192
+ description:
193
+ "PlanEvaluatorAgent | PlanAdversaryAgent | ReviewIntegratorAgent | HypothesisValidatorAgent | SprintContractAuditorAgent",
194
+ }),
195
+ kind: Type.String({
196
+ description: "claim | rebuttal | integrate | audit | system",
197
+ }),
198
+ body: Type.String(),
199
+ to: Type.Optional(Type.Array(Type.String())),
200
+ in_reply_to: Type.Optional(Type.Array(Type.String())),
201
+ claim_ids: Type.Optional(Type.Array(Type.String())),
202
+ evidence_refs: Type.Optional(Type.Array(Type.String())),
203
+ artifact_path: Type.Optional(Type.String()),
204
+ }),
205
+ async execute(_id, params, _signal, _onUpdate, ctx) {
206
+ const runId = getRunId(ctx);
207
+ const p = params as {
208
+ round_index: number;
209
+ from: DebateParticipant;
210
+ kind: "claim" | "rebuttal" | "integrate" | "audit" | "system";
211
+ body: string;
212
+ to?: Array<DebateParticipant | "broadcast">;
213
+ in_reply_to?: string[];
214
+ claim_ids?: string[];
215
+ evidence_refs?: string[];
216
+ artifact_path?: string;
217
+ };
218
+ const msg = await postMessengerMessage(runDir(process.cwd(), runId), {
219
+ from: p.from,
220
+ kind: p.kind,
221
+ round_index: p.round_index,
222
+ to: p.to ?? ["broadcast"],
223
+ body: p.body,
224
+ in_reply_to: p.in_reply_to ?? [],
225
+ claim_ids: p.claim_ids ?? [],
226
+ evidence_refs: p.evidence_refs ?? [],
227
+ artifact_path: p.artifact_path,
228
+ });
229
+ return {
230
+ content: [
231
+ {
232
+ type: "text",
233
+ text: `Posted ${msg.kind} from ${msg.from} (round ${msg.round_index}, id ${msg.id})`,
234
+ },
235
+ ],
236
+ details: { message: msg },
237
+ };
238
+ },
239
+ });
240
+
241
+ pi.registerTool({
242
+ name: "harness_messenger_read_round",
243
+ label: "Read Debate Round Transcript",
244
+ description:
245
+ "Return formatted messenger transcript for spawning adversary or integrator with full thread context.",
246
+ parameters: Type.Object({
247
+ round_index: Type.Number(),
248
+ }),
249
+ async execute(_id, params, _signal, _onUpdate, ctx) {
250
+ const runId = getRunId(ctx);
251
+ const roundIndex = Number(
252
+ (params as { round_index: number }).round_index,
253
+ );
254
+ const messages = await readRoundTranscript(
255
+ runDir(process.cwd(), runId),
256
+ roundIndex,
257
+ );
258
+ const text = formatTranscriptForSpawn(messages);
259
+ return {
260
+ content: [{ type: "text", text }],
261
+ details: { round_index: roundIndex, message_count: messages.length },
262
+ };
263
+ },
264
+ });
265
+
266
+ pi.registerTool({
267
+ name: "harness_debate_submit_round",
268
+ label: "Submit Plan Review Round",
269
+ description:
270
+ "Validate lane YAML + messenger thread, write review-round-rN.yaml, emit bus round envelope. Parent must not write review-round files directly.",
271
+ parameters: Type.Object({
272
+ round_index: Type.Number({ description: "1–4" }),
273
+ integrator_draft: Type.Record(Type.String(), Type.Unknown(), {
274
+ description: "ReviewIntegrator YAML object (review-round-rN fields)",
275
+ }),
276
+ }),
277
+ async execute(_id, params, _signal, _onUpdate, ctx) {
278
+ const runId = getRunId(ctx);
279
+ const projectRoot = process.cwd();
280
+ const roundIndex = Number(
281
+ (params as { round_index: number }).round_index,
282
+ );
283
+ const draft = (params as { integrator_draft: Record<string, unknown> })
284
+ .integrator_draft as unknown as PlanReviewRoundDraft;
285
+ draft.round_index = roundIndex;
286
+ if (!draft.schema_version) draft.schema_version = "1.0.0";
287
+ const debateId = planDebateIdForRun(runId);
288
+ const rd = runDir(projectRoot, runId);
289
+ const integratorBody =
290
+ (typeof draft.round_summary === "string" && draft.round_summary) ||
291
+ "Review integrator synthesis for this round.";
292
+ await postMessengerMessage(rd, {
293
+ from: "ReviewIntegratorAgent",
294
+ kind: "integrate",
295
+ round_index: roundIndex,
296
+ to: ["broadcast"],
297
+ body: integratorBody,
298
+ in_reply_to: [],
299
+ claim_ids: [],
300
+ evidence_refs: [`artifacts/review-round-r${roundIndex}.yaml`],
301
+ });
302
+
303
+ const roundState = await getMessengerRoundState(rd, roundIndex);
304
+ const mCheck = messengerRoundDebateReady(roundState, roundIndex === 4);
305
+ if (!mCheck.ok) {
306
+ return {
307
+ content: [
308
+ {
309
+ type: "text",
310
+ text: `Messenger gate failed:\n- ${mCheck.errors.join("\n- ")}`,
311
+ },
312
+ ],
313
+ details: { errors: mCheck.errors },
314
+ isError: true,
315
+ };
316
+ }
317
+
318
+ const validationTurn = await loadValidationTurnYaml(rd, roundIndex);
319
+ const integratorValidation = validateIntegratorDraft(
320
+ draft as unknown as Record<string, unknown>,
321
+ { validationTurn },
322
+ );
323
+ if (!integratorValidation.ok) {
324
+ return {
325
+ content: [
326
+ {
327
+ type: "text",
328
+ text: `Integrator rules failed:\n- ${integratorValidation.errors.join("\n- ")}`,
329
+ },
330
+ ],
331
+ details: { errors: integratorValidation.errors },
332
+ isError: true,
333
+ };
334
+ }
335
+ draft.review_gate_ready = integratorValidation.review_gate_ready;
336
+
337
+ const relPath = `artifacts/review-round-r${roundIndex}.yaml`;
338
+ const absPath = join(rd, relPath);
339
+ await withReviewRoundYamlWrite(async () => {
340
+ await mkdir(dirname(absPath), { recursive: true });
341
+ await writeYamlFile(absPath, draft);
342
+ });
343
+
344
+ const envelope = buildPlanReviewRoundEnvelope(draft, {
345
+ runId,
346
+ debateId,
347
+ });
348
+ const busState = getDebateState();
349
+ if (!busState || busState.debate_id !== debateId) {
350
+ await openDebateBus(runId, debateId, debateHooks(pi));
351
+ }
352
+ const result = await acceptDebateRound(envelope, debateHooks(pi));
353
+ if (!result.ok) {
354
+ return {
355
+ content: [
356
+ {
357
+ type: "text",
358
+ text: `Bus round rejected: ${result.reason ?? "unknown"}`,
359
+ },
360
+ ],
361
+ details: { envelope },
362
+ isError: true,
363
+ };
364
+ }
365
+
366
+ const sessionId = ctx.sessionManager.getSessionId();
367
+ telemetryRound(sessionId, {
368
+ run_id: runId,
369
+ debate_id: debateId,
370
+ round_index: roundIndex,
371
+ review_gate_ready: draft.review_gate_ready,
372
+ messenger_messages: roundState?.claim_count,
373
+ });
374
+
375
+ return {
376
+ content: [
377
+ {
378
+ type: "text",
379
+ text: `Round ${roundIndex} submitted to ${debateId} (review_gate_ready=${draft.review_gate_ready})`,
380
+ },
381
+ ],
382
+ details: {
383
+ path: relPath,
384
+ envelope,
385
+ review_gate_ready: draft.review_gate_ready,
386
+ warnings: integratorValidation.warnings,
387
+ },
388
+ };
389
+ },
390
+ });
391
+
392
+ pi.registerTool({
393
+ name: "harness_debate_consensus",
394
+ label: "Finalize Plan Debate Consensus",
395
+ description:
396
+ "After 4 bus rounds, emit consensus packet to .pi/harness/debates/plan-<run_id>.consensus.json",
397
+ parameters: Type.Object({
398
+ rationale: Type.Optional(Type.String()),
399
+ }),
400
+ async execute(_id, params, _signal, _onUpdate, ctx) {
401
+ const runId = getRunId(ctx);
402
+ const rationale =
403
+ String((params as { rationale?: string }).rationale ?? "").trim() ||
404
+ "Plan Review Gate consensus after 4 messenger-backed rounds.";
405
+ const decision = await finalizeDebateConsensus(
406
+ rationale,
407
+ debateHooks(pi),
408
+ );
409
+ const debateId = planDebateIdForRun(runId);
410
+ captureHarnessEvent(
411
+ ctx.sessionManager.getSessionId(),
412
+ "harness_debate_consensus",
413
+ {
414
+ run_id: runId,
415
+ debate_id: debateId,
416
+ policy_decision: decision,
417
+ },
418
+ );
419
+ return {
420
+ content: [
421
+ {
422
+ type: "text",
423
+ text: `Consensus: ${decision ?? "unknown"} (${debateId})`,
424
+ },
425
+ ],
426
+ details: { policy_decision: decision, debate_id: debateId },
427
+ };
428
+ },
429
+ });
430
+
431
+ pi.registerTool({
432
+ name: "harness_debate_apply_lane",
433
+ label: "Apply Debate Lane YAML + Messenger",
434
+ description:
435
+ "Parse subagent lane output, write artifacts/*-rN.yaml, and post evaluator claims / adversary rebuttals to messenger. Prefer letting subagent tool_result auto-apply; use this if auto-apply missed fenced YAML.",
436
+ parameters: Type.Object({
437
+ lane: Type.String({
438
+ description:
439
+ "hypothesis-validation | validation-turn | adversary-brief | sprint-audit",
440
+ }),
441
+ content: Type.String({ description: "Fenced YAML/JSON from subagent" }),
442
+ round_index: Type.Optional(Type.Number()),
443
+ }),
444
+ async execute(_id, params, _signal, _onUpdate, ctx) {
445
+ const runId = getRunId(ctx);
446
+ const p = params as {
447
+ lane: DebateLaneKind;
448
+ content: string;
449
+ round_index?: number;
450
+ };
451
+ const result = await applyDebateLane({
452
+ runDir: runDir(process.cwd(), runId),
453
+ lane: p.lane,
454
+ content: p.content,
455
+ roundIndex: p.round_index,
456
+ });
457
+ return {
458
+ content: [{ type: "text", text: formatApplyLaneMessage(result) }],
459
+ details: result,
460
+ isError: !result.ok,
461
+ };
462
+ },
463
+ });
464
+
465
+ pi.registerTool({
466
+ name: "harness_debate_round_status",
467
+ label: "Plan Debate Round Status",
468
+ description:
469
+ "List missing lane artifacts and messenger steps for a Review Gate round. Call when resuming after a stop.",
470
+ parameters: Type.Object({
471
+ round_index: Type.Number({ description: "1–4" }),
472
+ }),
473
+ async execute(_id, params, _signal, _onUpdate, ctx) {
474
+ const runId = getRunId(ctx);
475
+ const roundIndex = Number(
476
+ (params as { round_index: number }).round_index,
477
+ );
478
+ const status = await getPlanDebateRoundStatus(
479
+ runDir(process.cwd(), runId),
480
+ roundIndex,
481
+ );
482
+ const lines = [
483
+ `Round ${roundIndex}: ready_for_integrator=${status.ready_for_integrator}`,
484
+ status.missing.length
485
+ ? `Missing:\n- ${status.missing.join("\n- ")}`
486
+ : "Lane + messenger prerequisites satisfied.",
487
+ status.next_tool ? `Next: ${status.next_tool}` : "",
488
+ ].filter(Boolean);
489
+ return {
490
+ content: [{ type: "text", text: lines.join("\n\n") }],
491
+ details: status,
492
+ };
493
+ },
494
+ });
495
+
496
+ pi.registerTool({
497
+ name: "harness_plan_scope_check",
498
+ label: "Plan Scope Drift Check",
499
+ description:
500
+ "P2 guard: compare task_summary with decomposition text; returns material_drift when plan narrows to infra-only work.",
501
+ parameters: Type.Object({
502
+ task_summary: Type.String(),
503
+ decomposition_text: Type.String(),
504
+ }),
505
+ async execute(_id, params) {
506
+ const p = params as { task_summary: string; decomposition_text: string };
507
+ const result = assessPlanScopeDrift(p.task_summary, p.decomposition_text);
508
+ return {
509
+ content: [
510
+ {
511
+ type: "text",
512
+ text: `${result.summary}\nmaterial_drift=${result.material_drift} overlap=${result.overlap_score.toFixed(3)}`,
513
+ },
514
+ ],
515
+ details: result,
516
+ };
517
+ },
518
+ });
519
+ }
@@ -20,8 +20,10 @@ import {
20
20
  executeCreatePlan,
21
21
  formatCreatePlanResultText,
22
22
  } from "./lib/plan-approval/create-plan.js";
23
- import { runPlanApprovalDialog } from "./lib/plan-approval/dialog.js";
24
- import { runPlanApprovalFallback } from "./lib/plan-approval/fallback.js";
23
+ import {
24
+ buildPlanApprovalMarkdown,
25
+ runPlanApprovalDialog,
26
+ } from "./lib/plan-approval/dialog.js";
25
27
  import { writePlanReviewMarkdown } from "./lib/plan-approval/plan-review.js";
26
28
  import {
27
29
  renderApprovePlanCall,
@@ -42,6 +44,7 @@ import {
42
44
  toApprovePlanToolDetails,
43
45
  validateApprovePlanParams,
44
46
  } from "./lib/plan-approval/validate.js";
47
+ import { validatePlanDebateGate } from "./lib/plan-debate-gate.js";
45
48
 
46
49
  // @ts-expect-error pi extensions run as ESM
47
50
  const MODULE_URL = import.meta.url;
@@ -65,18 +68,23 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
65
68
  | {
66
69
  plan_packet?: unknown;
67
70
  human_summary?: string | null;
71
+ plan_markdown?: string | null;
68
72
  }
69
73
  | undefined;
70
74
  if (!data?.plan_packet) return undefined;
75
+ const contentText =
76
+ typeof message.content === "string" ? message.content : null;
71
77
  const lines = renderHarnessPlanDraft(
72
78
  {
73
79
  plan_packet: data.plan_packet as Parameters<
74
80
  typeof renderHarnessPlanDraft
75
81
  >[0]["plan_packet"],
76
82
  human_summary: data.human_summary,
83
+ plan_markdown: data.plan_markdown,
77
84
  },
78
- 80,
85
+ 120,
79
86
  theme,
87
+ contentText,
80
88
  );
81
89
  return new Text(lines.join("\n"), 0, 0);
82
90
  },
@@ -86,7 +94,7 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
86
94
  name: "approve_plan",
87
95
  label: "Approve Plan",
88
96
  description:
89
- "Present a PlanPacket for user approval with a scrollable plan view. Parent /harness-plan orchestrator calls this after decomposition, hypothesis, and parallel reviews.",
97
+ "Present a PlanPacket for user approval: full plan markdown in the transcript, then Approve / Request changes / Cancel via the same prompt as ask_user. Parent /harness-plan orchestrator calls this after decomposition, hypothesis, and parallel reviews.",
90
98
  promptSnippet: PROMPT_SNIPPET,
91
99
  promptGuidelines: PROMPT_GUIDELINES,
92
100
  parameters: ApprovePlanParamsSchema,
@@ -133,11 +141,30 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
133
141
  }
134
142
 
135
143
  const planId = String(validated.plan_packet.plan_id ?? "plan");
136
- const summary =
144
+ const _summary =
137
145
  validated.human_summary?.trim() ||
138
146
  `Plan ${planId} — pending your approval`;
139
147
  const runCtx = getLatestRunContext(entries);
140
148
  const projectRoot = process.cwd();
149
+ if (runCtx?.run_id) {
150
+ const gate = await validatePlanDebateGate(projectRoot, runCtx.run_id);
151
+ if (!gate.ok) {
152
+ return {
153
+ content: [
154
+ {
155
+ type: "text",
156
+ text: `approve_plan blocked — plan debate gate incomplete:\n- ${gate.errors.join("\n- ")}`,
157
+ },
158
+ ],
159
+ details: {
160
+ plan_packet: validated.plan_packet,
161
+ debate_gate: gate,
162
+ cancelled: true,
163
+ },
164
+ isError: true,
165
+ };
166
+ }
167
+ }
141
168
  const reviewPath = await writePlanReviewMarkdown(
142
169
  projectRoot,
143
170
  runCtx,
@@ -148,10 +175,11 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
148
175
  status: "draft",
149
176
  },
150
177
  );
178
+ const planMarkdown = buildPlanApprovalMarkdown(validated);
151
179
  const draftContent =
152
180
  reviewPath != null
153
- ? `${summary}\nEditor review: ${reviewPath}`
154
- : summary;
181
+ ? `${planMarkdown}\n\n---\n\nEditor copy: \`${reviewPath}\``
182
+ : planMarkdown;
155
183
  pi.sendMessage({
156
184
  customType: "harness-plan-draft",
157
185
  content: draftContent,
@@ -162,20 +190,16 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
162
190
  human_summary: validated.human_summary ?? null,
163
191
  research_brief: validated.research_brief ?? null,
164
192
  plan_review_path: reviewPath,
193
+ plan_markdown: planMarkdown,
165
194
  shown_at: new Date().toISOString(),
166
195
  },
167
196
  });
168
197
 
169
- let outcome: PlanApprovalDialogResult;
170
- if (ctx.hasUI) {
171
- outcome = await runPlanApprovalDialog(ctx.ui, validated, {
172
- onMounted: () => {
173
- pi.events.emit("plan-approval:mounted", {});
174
- },
175
- });
176
- } else {
177
- outcome = await runPlanApprovalFallback(ctx.ui, validated);
178
- }
198
+ const outcome: PlanApprovalDialogResult = await runPlanApprovalDialog(
199
+ ctx.ui,
200
+ validated,
201
+ { hasUI: ctx.hasUI },
202
+ );
179
203
 
180
204
  const details = toApprovePlanToolDetails(
181
205
  validated,
@@ -56,6 +56,8 @@ import {
56
56
  writeYamlFile,
57
57
  } from "../lib/harness-yaml.js";
58
58
  import { claimExtensionLoad } from "./lib/extension-load-guard.js";
59
+ import { isReviewRoundArtifactPath } from "./lib/plan-debate-gate.js";
60
+ import { isReviewRoundYamlWriteAllowed } from "./lib/plan-debate-write-guard.js";
59
61
 
60
62
  // @ts-expect-error pi extensions run as ESM
61
63
  const MODULE_URL = import.meta.url;
@@ -987,6 +989,22 @@ export default function harnessRunContext(pi: ExtensionAPI) {
987
989
  isError: true,
988
990
  };
989
991
  }
992
+ const relForGate = pathArg.replace(/\\/g, "/");
993
+ if (
994
+ isReviewRoundArtifactPath(relForGate) &&
995
+ !isReviewRoundYamlWriteAllowed()
996
+ ) {
997
+ return {
998
+ content: [
999
+ {
1000
+ type: "text",
1001
+ text: `Blocked: ${pathArg} must be written via harness_debate_submit_round after lane YAML + messenger thread are complete. Parent sessions cannot author review-round files directly.`,
1002
+ },
1003
+ ],
1004
+ details: { path: pathArg },
1005
+ isError: true,
1006
+ };
1007
+ }
990
1008
  let doc: unknown;
991
1009
  try {
992
1010
  doc = parseStructuredDocument(content, pathArg);