ultimate-pi 0.23.0 → 0.25.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 (62) hide show
  1. package/.pi/extensions/agt-prompt-guard.ts +20 -6
  2. package/.pi/extensions/harness-ask-user.ts +14 -5
  3. package/.pi/extensions/harness-auto-compact.ts +94 -0
  4. package/.pi/extensions/harness-debate-tools.ts +59 -4
  5. package/.pi/extensions/harness-live-widget.ts +25 -0
  6. package/.pi/extensions/harness-plan-approval.ts +65 -15
  7. package/.pi/extensions/harness-plan-orchestration.ts +140 -0
  8. package/.pi/extensions/harness-run-context.ts +501 -48
  9. package/.pi/extensions/harness-telemetry.ts +1 -0
  10. package/.pi/extensions/harness-web-tools.ts +1 -0
  11. package/.pi/extensions/policy-gate.ts +9 -0
  12. package/.pi/extensions/trace-recorder.ts +1 -0
  13. package/.pi/harness/agents.manifest.json +1 -1
  14. package/.pi/harness/docs/adrs/0056-agent-native-speed-wiring.md +26 -0
  15. package/.pi/harness/env.harness.template +14 -0
  16. package/.pi/harness/specs/harness-posthog-event.schema.json +2 -0
  17. package/.pi/harness/specs/sentrux-signal.schema.json +1 -1
  18. package/.pi/lib/harness-auto-approve.ts +140 -0
  19. package/.pi/lib/harness-auto-compact-policy.ts +85 -0
  20. package/.pi/lib/harness-cocoindex-refresh.ts +82 -2
  21. package/.pi/lib/harness-phase-telemetry.ts +81 -0
  22. package/.pi/lib/harness-phase-worker.ts +23 -0
  23. package/.pi/lib/harness-plan-fsm.ts +162 -0
  24. package/.pi/lib/harness-plan-route.ts +134 -0
  25. package/.pi/lib/harness-posthog.ts +6 -1
  26. package/.pi/lib/harness-remediation.ts +79 -0
  27. package/.pi/lib/harness-repair-brief.ts +2 -2
  28. package/.pi/lib/harness-review-parallel.ts +18 -0
  29. package/.pi/lib/harness-run-context.ts +119 -72
  30. package/.pi/lib/harness-spawn-budget.ts +32 -4
  31. package/.pi/lib/harness-spawn-stall-detector.ts +106 -0
  32. package/.pi/lib/harness-spawn-topology.ts +50 -1
  33. package/.pi/lib/harness-subagent-precheck.ts +41 -0
  34. package/.pi/lib/harness-subagent-progress.ts +119 -0
  35. package/.pi/lib/harness-subagent-timeout.ts +81 -0
  36. package/.pi/lib/harness-subagents-bridge.ts +94 -8
  37. package/.pi/lib/harness-ui-state.ts +5 -0
  38. package/.pi/lib/harness-vcc-settings.ts +36 -0
  39. package/.pi/lib/plan-approval-readiness.ts +9 -5
  40. package/.pi/lib/plan-debate-eligibility-snapshot.ts +90 -0
  41. package/.pi/lib/plan-debate-eligibility.ts +16 -9
  42. package/.pi/lib/plan-debate-focus.ts +23 -11
  43. package/.pi/lib/plan-debate-gate.ts +94 -31
  44. package/.pi/lib/plan-debate-round-status.ts +23 -8
  45. package/.pi/lib/plan-debate-wall-clock.ts +57 -0
  46. package/.pi/lib/plan-headless-ux.ts +598 -0
  47. package/.pi/lib/plan-human-gates.ts +24 -85
  48. package/.pi/lib/plan-messenger.ts +3 -3
  49. package/.pi/lib/plan-review-gate.ts +56 -0
  50. package/.pi/prompts/harness-abort.md +1 -0
  51. package/.pi/prompts/harness-auto.md +1 -1
  52. package/.pi/prompts/harness-clear.md +6 -6
  53. package/.pi/prompts/harness-plan.md +15 -2
  54. package/.pi/prompts/harness-review.md +26 -12
  55. package/.pi/scripts/harness-e2e-workflow.mjs +94 -0
  56. package/.pi/scripts/harness-project-toggle.mjs +1 -1
  57. package/.pi/scripts/harness-sentrux-cli.mjs +26 -1
  58. package/.pi/scripts/harness-sentrux-report.mjs +41 -6
  59. package/CHANGELOG.md +16 -0
  60. package/README.md +2 -2
  61. package/package.json +1 -1
  62. package/vendor/pi-subagents/src/subagents.ts +41 -10
@@ -4,21 +4,35 @@
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { PromptDefenseEvaluator } from "@microsoft/agent-governance-sdk";
7
+ import { isHarnessNonInteractive } from "../lib/ask-user/policy.js";
7
8
  import { isHarnessProjectEnabled } from "../lib/harness-project-config.js";
8
- import { userVisiblePromptSlice } from "../lib/harness-run-context.js";
9
+ import { harnessSlashCommandLineForPolicy } from "../lib/harness-run-context.js";
9
10
 
10
11
  const evaluator = new PromptDefenseEvaluator({ minGrade: "D" });
11
12
 
12
13
  export default function agtPromptGuard(pi: ExtensionAPI) {
13
14
  if (!isHarnessProjectEnabled()) return;
14
15
 
15
- pi.on("before_agent_start", async (event) => {
16
- const prompt = userVisiblePromptSlice(event.prompt);
17
- if (!prompt.trim()) return undefined;
18
- if (!/\/harness-/.test(prompt)) return undefined;
16
+ pi.on("before_agent_start", async (event, ctx) => {
17
+ const commandLine = harnessSlashCommandLineForPolicy(
18
+ event.prompt,
19
+ ctx.sessionManager.getEntries(),
20
+ );
21
+ if (!commandLine) return undefined;
19
22
 
20
- const report = evaluator.evaluate(prompt);
23
+ const report = evaluator.evaluate(commandLine);
21
24
  if (report.isBlocking("D")) {
25
+ if (isHarnessNonInteractive()) {
26
+ pi.appendEntry("harness-policy-violation", {
27
+ source: "agt-prompt-guard",
28
+ display: false,
29
+ grade: report.grade,
30
+ score: report.score,
31
+ missing: report.missing,
32
+ advisory: true,
33
+ });
34
+ return undefined;
35
+ }
22
36
  return {
23
37
  message: {
24
38
  customType: "harness-policy-violation",
@@ -12,6 +12,7 @@ import {
12
12
  } from "../lib/ask-user/schema.js";
13
13
  import type { AskUserParams } from "../lib/ask-user/types.js";
14
14
  import { claimHarnessGovernanceLoad } from "../lib/extension-load-guard.js";
15
+ import { setHarnessWaitingForUser } from "../lib/harness-subagent-progress.js";
15
16
 
16
17
  // @ts-expect-error pi extensions run as ESM
17
18
  const MODULE_URL = import.meta.url;
@@ -28,11 +29,19 @@ export default function harnessAskUser(pi: ExtensionAPI) {
28
29
  parameters: AskUserParamsSchema,
29
30
 
30
31
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
31
- const result = await runAskUser(params as AskUserParams, {
32
- ui: ctx.ui,
33
- hasUI: ctx.hasUI,
34
- sessionName: undefined,
35
- });
32
+ setHarnessWaitingForUser("ask_user");
33
+ pi.events.emit("harness-waiting-for-user", { gate: "ask_user" });
34
+ let result: Awaited<ReturnType<typeof runAskUser>>;
35
+ try {
36
+ result = await runAskUser(params as AskUserParams, {
37
+ ui: ctx.ui,
38
+ hasUI: ctx.hasUI,
39
+ sessionName: undefined,
40
+ });
41
+ } finally {
42
+ setHarnessWaitingForUser(null);
43
+ pi.events.emit("harness-waiting-for-user", { gate: null });
44
+ }
36
45
 
37
46
  if ("error" in result) {
38
47
  return {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Harness auto-compact at 50% context usage (VCC-backed).
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { claimExtensionLoad } from "../lib/extension-load-guard.js";
7
+ import {
8
+ type CompactGateState,
9
+ createCompactGateState,
10
+ evaluateAutoCompactGate,
11
+ onCompactCancel,
12
+ onSessionCompact,
13
+ } from "../lib/harness-auto-compact-policy.js";
14
+ import { captureHarnessEvent } from "../lib/harness-debate-core-deps.js";
15
+
16
+ // @ts-expect-error pi extensions run as ESM
17
+ const MODULE_URL = import.meta.url;
18
+
19
+ const gateBySession = new Map<string, CompactGateState>();
20
+
21
+ function gateForSession(sessionId: string): CompactGateState {
22
+ let state = gateBySession.get(sessionId);
23
+ if (!state) {
24
+ state = createCompactGateState();
25
+ gateBySession.set(sessionId, state);
26
+ }
27
+ return state;
28
+ }
29
+
30
+ export default function harnessAutoCompact(pi: ExtensionAPI): void {
31
+ if (!claimExtensionLoad("harness-auto-compact", MODULE_URL)) return;
32
+
33
+ pi.on("tool_execution_start", (event, ctx) => {
34
+ if (event.toolName !== "subagent") return;
35
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
36
+ if (!sessionId) return;
37
+ gateForSession(sessionId).subagentSpawnPending = true;
38
+ });
39
+
40
+ pi.on("tool_execution_end", (event, ctx) => {
41
+ if (event.toolName !== "subagent") return;
42
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
43
+ if (!sessionId) return;
44
+ gateForSession(sessionId).subagentSpawnPending = false;
45
+ });
46
+
47
+ pi.on("session_compact", (_event, ctx) => {
48
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
49
+ if (!sessionId) return;
50
+ onSessionCompact(gateForSession(sessionId));
51
+ });
52
+
53
+ pi.on("agent_end", async (_message, ctx) => {
54
+ const sessionId = ctx.sessionManager.getSessionId();
55
+ const state = gateForSession(sessionId);
56
+ if (state.cooldownTurns > 0) {
57
+ state.cooldownTurns -= 1;
58
+ }
59
+ const usage = ctx.getContextUsage();
60
+ if (!usage) return;
61
+ const isSubagent = process.env.PI_HARNESS_SUBPROCESS === "1";
62
+ const decision = evaluateAutoCompactGate(
63
+ {
64
+ percent: usage.percent ?? 0,
65
+ tokens: usage.tokens ?? undefined,
66
+ contextWindow: usage.contextWindow ?? undefined,
67
+ },
68
+ state,
69
+ { isSubagent },
70
+ );
71
+ if (!decision.shouldCompact) return;
72
+ state.inFlight = true;
73
+ try {
74
+ await ctx.compact({
75
+ onComplete: (result) => {
76
+ const cancelled =
77
+ (result as { cancel?: boolean } | undefined)?.cancel === true;
78
+ if (cancelled) {
79
+ onCompactCancel(state);
80
+ }
81
+ captureHarnessEvent(sessionId, "harness_auto_compact", {
82
+ percent: usage.percent ?? 0,
83
+ tokens_before: usage.tokens ?? undefined,
84
+ context_window: usage.contextWindow ?? undefined,
85
+ compactor: "ultimate-pi-vcc",
86
+ cancelled,
87
+ });
88
+ },
89
+ });
90
+ } catch {
91
+ state.inFlight = false;
92
+ }
93
+ });
94
+ }
@@ -5,6 +5,7 @@
5
5
  import { mkdir, readFile } from "node:fs/promises";
6
6
  import { dirname, join } from "node:path";
7
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { isHarnessNonInteractive } from "../lib/ask-user/policy.js";
8
9
  import { claimHarnessGovernanceLoad } from "../lib/extension-load-guard.js";
9
10
  import {
10
11
  captureHarnessEvent,
@@ -50,7 +51,20 @@ import {
50
51
  validateIntegratorDraft,
51
52
  withReviewRoundYamlWrite,
52
53
  } from "../lib/harness-debate-workflow-deps.js";
54
+ import {
55
+ loadPlanDebateEligibilitySnapshot,
56
+ writePlanDebateEligibilitySnapshot,
57
+ } from "../lib/plan-debate-eligibility-snapshot.js";
58
+ import {
59
+ checkDebateWallClock,
60
+ debateWallClockRecoveryHint,
61
+ } from "../lib/plan-debate-wall-clock.js";
62
+ import {
63
+ planReviewGateModeForProfile,
64
+ planReviewGateStrategyFromEligibility,
65
+ } from "../lib/plan-review-gate.js";
53
66
 
67
+ // @ts-expect-error pi extensions run as ESM
54
68
  const MODULE_URL = import.meta.url;
55
69
 
56
70
  function getRunId(ctx: {
@@ -231,6 +245,7 @@ function registerHarnessDebateHandler2(pi: ExtensionAPI) {
231
245
  ),
232
246
  };
233
247
  const result = harnessPlanDebateEligibility(input);
248
+ await writePlanDebateEligibilitySnapshot(rd, result);
234
249
  const lines = [
235
250
  `profile: ${result.profile}`,
236
251
  `review_gate_mode: ${result.review_gate_strategy.mode}`,
@@ -266,6 +281,11 @@ function registerHarnessDebateHandler3(pi: ExtensionAPI) {
266
281
  Type.String({ description: "spec | wbs | schedule | quality" }),
267
282
  ),
268
283
  ),
284
+ review_gate_mode: Type.Optional(
285
+ Type.String({
286
+ description: "consolidated | threaded | parallel_probes",
287
+ }),
288
+ ),
269
289
  }),
270
290
  async execute(_id, params, _signal, _onUpdate, ctx) {
271
291
  const runId = getRunId(ctx);
@@ -274,6 +294,7 @@ function registerHarnessDebateHandler3(pi: ExtensionAPI) {
274
294
  debate_id?: string;
275
295
  debate_profile?: string;
276
296
  required_focuses?: string[];
297
+ review_gate_mode?: string;
277
298
  };
278
299
  const raw = String(p.debate_id ?? "");
279
300
  const { debateId, corrected, warning } = normalizePlanDebateId(
@@ -290,14 +311,23 @@ function registerHarnessDebateHandler3(pi: ExtensionAPI) {
290
311
  const required_focuses = (p.required_focuses ?? []).filter((f) =>
291
312
  ["spec", "wbs", "schedule", "quality"].includes(f),
292
313
  ) as Array<"spec" | "wbs" | "schedule" | "quality">;
314
+ const rd = runDir(projectRoot, runId);
315
+ const eligibilitySnapshot = await loadPlanDebateEligibilitySnapshot(rd);
293
316
  const opened = await openDebateBus(runId, debateId, debateHooks(pi), {
294
317
  debate_profile: profile,
295
318
  required_focuses:
296
319
  required_focuses.length > 0 ? required_focuses : undefined,
297
320
  });
321
+ const explicitMode = p.review_gate_mode;
298
322
  const review_gate_mode =
299
- profile === "fast" ? ("consolidated" as const) : ("threaded" as const);
300
- await initPlanMessenger(runDir(projectRoot, runId), {
323
+ explicitMode === "consolidated" ||
324
+ explicitMode === "threaded" ||
325
+ explicitMode === "parallel_probes"
326
+ ? explicitMode
327
+ : eligibilitySnapshot
328
+ ? planReviewGateStrategyFromEligibility(eligibilitySnapshot).mode
329
+ : planReviewGateModeForProfile(profile);
330
+ await initPlanMessenger(rd, {
301
331
  runId,
302
332
  debateId,
303
333
  debate_profile: profile,
@@ -444,6 +474,24 @@ function registerHarnessDebateHandler6(pi: ExtensionAPI) {
444
474
  async execute(_id, params, _signal, _onUpdate, ctx) {
445
475
  const runId = getRunId(ctx);
446
476
  const projectRoot = process.cwd();
477
+ const rd = runDir(projectRoot, runId);
478
+ const messenger = await loadMessengerState(rd);
479
+ const wall = checkDebateWallClock({
480
+ opened_at: messenger?.opened_at,
481
+ debate_profile: messenger?.debate_profile,
482
+ });
483
+ if (wall.exceeded && !isHarnessNonInteractive()) {
484
+ return {
485
+ content: [
486
+ {
487
+ type: "text",
488
+ text: debateWallClockRecoveryHint(wall),
489
+ },
490
+ ],
491
+ details: { wall_clock: wall },
492
+ isError: true,
493
+ };
494
+ }
447
495
  const roundIndex = Number(
448
496
  (params as { round_index: number }).round_index,
449
497
  );
@@ -452,7 +500,6 @@ function registerHarnessDebateHandler6(pi: ExtensionAPI) {
452
500
  draft.round_index = roundIndex;
453
501
  if (!draft.schema_version) draft.schema_version = "1.0.0";
454
502
  const debateId = planDebateIdForRun(runId);
455
- const rd = runDir(projectRoot, runId);
456
503
  const integratorBody =
457
504
  (typeof draft.round_summary === "string" && draft.round_summary) ||
458
505
  "Review integrator synthesis for this round.";
@@ -571,9 +618,17 @@ function registerHarnessDebateHandler7(pi: ExtensionAPI) {
571
618
  }),
572
619
  async execute(_id, params, _signal, _onUpdate, ctx) {
573
620
  const runId = getRunId(ctx);
621
+ const projectRoot = process.cwd();
622
+ const messenger = await loadMessengerState(runDir(projectRoot, runId));
623
+ const wall = checkDebateWallClock({
624
+ opened_at: messenger?.opened_at,
625
+ debate_profile: messenger?.debate_profile,
626
+ });
574
627
  const rationale =
575
628
  String((params as { rationale?: string }).rationale ?? "").trim() ||
576
- "Plan Review Gate consensus after focus coverage and messenger-backed rounds.";
629
+ (wall.exceeded && isHarnessNonInteractive()
630
+ ? "Debate truncated at wall-clock cap (non-interactive conditional_pass)."
631
+ : "Plan Review Gate consensus after focus coverage and messenger-backed rounds.");
577
632
  const decision = await finalizeDebateConsensus(
578
633
  rationale,
579
634
  debateHooks(pi),
@@ -4,6 +4,7 @@ import type {
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { isHarnessProjectEnabled } from "../lib/harness-project-config.js";
6
6
  import { evaluateCrossSessionResume } from "../lib/harness-run-context.js";
7
+ import { buildHarnessProgressStatusLine } from "../lib/harness-subagent-progress.js";
7
8
  import {
8
9
  deriveHarnessStatusHint,
9
10
  formatHarnessPhaseLabel,
@@ -311,6 +312,14 @@ export default function harnessLiveWidget(pi: ExtensionAPI) {
311
312
  if (mountCtx) scheduleRefresh(mountCtx);
312
313
  });
313
314
 
315
+ pi.events.on("harness-progress:updated", () => {
316
+ if (mountCtx) scheduleProgressRefresh(mountCtx);
317
+ });
318
+
319
+ pi.events.on("harness-waiting-for-user", () => {
320
+ if (mountCtx) scheduleProgressRefresh(mountCtx);
321
+ });
322
+
314
323
  pi.events.on("harness-cross-session-resume", (payload: unknown) => {
315
324
  const data =
316
325
  payload && typeof payload === "object"
@@ -358,9 +367,25 @@ export default function harnessLiveWidget(pi: ExtensionAPI) {
358
367
  flowSubstate: state.flowSubstate,
359
368
  nextRecommendedCommand: state.nextRecommendedCommand,
360
369
  crossSessionResumeCommand: state.crossSessionResumeCommand,
370
+ progressLine: buildHarnessProgressStatusLine(),
361
371
  });
362
372
  }
363
373
 
374
+ /** Re-render widget when elapsed-time progress changes (bypasses hash short-circuit). */
375
+ function scheduleProgressRefresh(ctx: ExtensionContext): void {
376
+ if (!isHarnessProjectEnabled()) {
377
+ clearHarnessWidget(ctx);
378
+ return;
379
+ }
380
+ const state = stateStore.refresh(ctx);
381
+ const hash = computeRenderHash(state);
382
+ updateStatusFallback(ctx, state);
383
+ lastRenderHash = hash;
384
+ if (component) component.setData(state);
385
+ component?.invalidate();
386
+ tuiHandle?.requestRender();
387
+ }
388
+
364
389
  function scheduleRefresh(ctx: ExtensionContext): void {
365
390
  if (refreshQueued) return;
366
391
  refreshQueued = true;
@@ -2,9 +2,11 @@
2
2
  * harness-plan-approval — PlanPacket approval UI and transcript renderer for parent sessions.
3
3
  */
4
4
 
5
+ import { join } from "node:path";
5
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
7
  import { Text } from "@earendil-works/pi-tui";
7
8
  import { claimHarnessGovernanceLoad } from "../lib/extension-load-guard.js";
9
+ import { tryAutoApprovePlan } from "../lib/harness-auto-approve.js";
8
10
  import type { PlanPacketLike } from "../lib/harness-run-context.js";
9
11
  import {
10
12
  appendPlanApprovalIfNew,
@@ -13,6 +15,7 @@ import {
13
15
  parsePlanApprovalFromMessage,
14
16
  planPacketSummary,
15
17
  } from "../lib/harness-run-context.js";
18
+ import { setHarnessWaitingForUser } from "../lib/harness-subagent-progress.js";
16
19
  import {
17
20
  CREATE_PLAN_GUIDELINES,
18
21
  CREATE_PLAN_SNIPPET,
@@ -46,6 +49,7 @@ import {
46
49
  validateApprovePlanParams,
47
50
  } from "../lib/plan-approval/validate.js";
48
51
  import { validatePlanApprovalReadiness } from "../lib/plan-approval-readiness.js";
52
+ import { loadPlanDebateEligibilitySnapshot } from "../lib/plan-debate-eligibility-snapshot.js";
49
53
  import { validatePlanDebateGate } from "../lib/plan-debate-gate.js";
50
54
 
51
55
  // @ts-expect-error pi extensions run as ESM
@@ -165,40 +169,58 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
165
169
  const risk = String(
166
170
  validated.plan_packet.risk_level ?? "med",
167
171
  ).toLowerCase();
172
+ let readinessResult: Awaited<
173
+ ReturnType<typeof validatePlanApprovalReadiness>
174
+ > | null = null;
175
+ let debateGateResult: Awaited<
176
+ ReturnType<typeof validatePlanDebateGate>
177
+ > | null = null;
168
178
  if (runCtx?.run_id) {
169
- const readiness = await validatePlanApprovalReadiness(
179
+ readinessResult = await validatePlanApprovalReadiness(
170
180
  projectRoot,
171
181
  runCtx.run_id,
172
182
  { risk_level: risk },
173
183
  );
174
- if (!readiness.ok) {
184
+ if (!readinessResult.ok) {
175
185
  return {
176
186
  content: [
177
187
  {
178
188
  type: "text",
179
- text: `approve_plan blocked — plan phase not ready:\n- ${readiness.errors.join("\n- ")}`,
189
+ text: `approve_plan blocked — plan phase not ready:\n- ${readinessResult.errors.join("\n- ")}`,
180
190
  },
181
191
  ],
182
192
  details: {
183
193
  plan_packet: validated.plan_packet,
184
- readiness,
194
+ readiness: readinessResult,
185
195
  cancelled: true,
186
196
  },
187
197
  isError: true,
188
198
  };
189
199
  }
190
- implWarnings.push(...readiness.warnings);
200
+ implWarnings.push(...readinessResult.warnings);
191
201
  }
192
202
  if (runCtx?.run_id) {
193
- const gate = await validatePlanDebateGate(projectRoot, runCtx.run_id);
194
- if (!gate.ok) {
203
+ const runDir = join(
204
+ projectRoot,
205
+ ".pi",
206
+ "harness",
207
+ "runs",
208
+ runCtx.run_id,
209
+ );
210
+ const eligibility = await loadPlanDebateEligibilitySnapshot(runDir);
211
+ debateGateResult = await validatePlanDebateGate(
212
+ projectRoot,
213
+ runCtx.run_id,
214
+ eligibility ?? undefined,
215
+ );
216
+ if (!debateGateResult.ok) {
195
217
  const { buildPlanDebateGateRecovery } = await import(
196
218
  "../lib/plan-debate-gate.js"
197
219
  );
198
220
  const recovery = await buildPlanDebateGateRecovery(
199
221
  projectRoot,
200
222
  runCtx.run_id,
201
- gate,
223
+ debateGateResult,
202
224
  );
203
225
  return {
204
226
  content: [
@@ -209,13 +231,24 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
209
231
  ],
210
232
  details: {
211
233
  plan_packet: validated.plan_packet,
212
- debate_gate: gate,
234
+ debate_gate: debateGateResult,
213
235
  cancelled: true,
214
236
  },
215
237
  isError: true,
216
238
  };
217
239
  }
218
240
  }
241
+ const autoOutcome =
242
+ runCtx?.run_id && readinessResult && debateGateResult
243
+ ? await tryAutoApprovePlan({
244
+ projectRoot,
245
+ runId: runCtx.run_id,
246
+ riskLevel: risk,
247
+ readiness: readinessResult,
248
+ debateGate: debateGateResult,
249
+ })
250
+ : { approved: false, reasons: [] };
251
+
219
252
  const reviewPath = await writePlanReviewMarkdown(
220
253
  projectRoot,
221
254
  runCtx,
@@ -223,7 +256,7 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
223
256
  {
224
257
  human_summary: validated.human_summary,
225
258
  research_brief: validated.research_brief,
226
- status: "draft",
259
+ status: autoOutcome.approved ? "approved" : "draft",
227
260
  },
228
261
  );
229
262
  const planMarkdown = buildPlanApprovalMarkdown(validated);
@@ -246,11 +279,28 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
246
279
  },
247
280
  });
248
281
 
249
- const outcome: PlanApprovalDialogResult = await runPlanApprovalDialog(
250
- ctx.ui,
251
- validated,
252
- { hasUI: ctx.hasUI },
253
- );
282
+ let outcome: PlanApprovalDialogResult;
283
+ if (autoOutcome.approved) {
284
+ outcome = {
285
+ response: {
286
+ kind: "selection",
287
+ selections: ["Approve"],
288
+ },
289
+ cancelled: false,
290
+ ui_backend: "headless",
291
+ };
292
+ } else {
293
+ setHarnessWaitingForUser("approve_plan");
294
+ pi.events.emit("harness-waiting-for-user", { gate: "approve_plan" });
295
+ try {
296
+ outcome = await runPlanApprovalDialog(ctx.ui, validated, {
297
+ hasUI: ctx.hasUI,
298
+ });
299
+ } finally {
300
+ setHarnessWaitingForUser(null);
301
+ pi.events.emit("harness-waiting-for-user", { gate: null });
302
+ }
303
+ }
254
304
 
255
305
  const details = toApprovePlanToolDetails(
256
306
  validated,
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Plan orchestration tools — FSM next action + synthesis route.
3
+ */
4
+
5
+ import { join } from "node:path";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { claimHarnessGovernanceLoad } from "../lib/extension-load-guard.js";
8
+ import {
9
+ captureHarnessEvent,
10
+ getRunIdFromSession,
11
+ Type,
12
+ } from "../lib/harness-debate-core-deps.js";
13
+ import { derivePlanNextAction } from "../lib/harness-plan-fsm.js";
14
+ import {
15
+ derivePlanRouteSpawns,
16
+ planSynthesisPath,
17
+ } from "../lib/harness-plan-route.js";
18
+
19
+ // @ts-expect-error pi extensions run as ESM
20
+ const MODULE_URL = import.meta.url;
21
+
22
+ function getRunId(ctx: {
23
+ sessionManager: { getEntries(): unknown[]; getSessionId(): string };
24
+ }): string {
25
+ return (
26
+ getRunIdFromSession(
27
+ ctx.sessionManager.getEntries(),
28
+ ctx.sessionManager.getSessionId(),
29
+ ) ?? ctx.sessionManager.getSessionId()
30
+ );
31
+ }
32
+
33
+ function runDir(projectRoot: string, runId: string): string {
34
+ return join(projectRoot, ".pi", "harness", "runs", runId);
35
+ }
36
+
37
+ export default function harnessPlanOrchestration(pi: ExtensionAPI) {
38
+ if (!claimHarnessGovernanceLoad("harness-plan-orchestration", MODULE_URL))
39
+ return;
40
+
41
+ pi.registerTool({
42
+ name: "harness_plan_next_action",
43
+ label: "Plan Next Action",
44
+ description:
45
+ "Deterministic plan-phase FSM: returns the next spawn, tool, gate, or wait_user action. Call before improvising orchestration steps.",
46
+ parameters: Type.Object({
47
+ quick: Type.Optional(Type.Boolean()),
48
+ task_summary: Type.Optional(Type.String()),
49
+ last_outcome: Type.Optional(Type.String()),
50
+ }),
51
+ async execute(_id, params, _signal, _onUpdate, ctx) {
52
+ const runId = getRunId(ctx);
53
+ const projectRoot = process.cwd();
54
+ const p = params as {
55
+ quick?: boolean;
56
+ task_summary?: string;
57
+ last_outcome?: string | null;
58
+ };
59
+ const entries = ctx.sessionManager.getEntries();
60
+ const next = await derivePlanNextAction({
61
+ projectRoot,
62
+ runId,
63
+ entries,
64
+ quick: p.quick,
65
+ taskSummary: p.task_summary,
66
+ lastOutcome: p.last_outcome,
67
+ });
68
+ const sessionId = ctx.sessionManager.getSessionId();
69
+ captureHarnessEvent(sessionId, "harness_plan_fsm", {
70
+ run_id: runId,
71
+ phase: next.phase,
72
+ action: next.action,
73
+ review_gate_mode: next.review_gate_mode,
74
+ synthesis_route: next.synthesis_route,
75
+ });
76
+ const lines = [
77
+ `phase: ${next.phase}`,
78
+ `action: ${next.action}`,
79
+ next.tool ? `tool: ${next.tool}` : null,
80
+ next.agents?.length ? `agents: ${next.agents.join(", ")}` : null,
81
+ next.review_gate_mode
82
+ ? `review_gate_mode: ${next.review_gate_mode}`
83
+ : null,
84
+ next.synthesis_route
85
+ ? `synthesis_route: ${next.synthesis_route}`
86
+ : null,
87
+ ...next.rationale.map((r) => `- ${r}`),
88
+ ].filter(Boolean);
89
+ return {
90
+ content: [{ type: "text", text: lines.join("\n") }],
91
+ details: next,
92
+ };
93
+ },
94
+ });
95
+
96
+ pi.registerTool({
97
+ name: "harness_plan_route",
98
+ label: "Plan Synthesis Route",
99
+ description:
100
+ "Returns sequential vs plan-synthesizer routing and next planning spawns from disk artifacts.",
101
+ parameters: Type.Object({
102
+ risk_level: Type.Optional(Type.String()),
103
+ material_fork: Type.Optional(Type.Boolean()),
104
+ }),
105
+ async execute(_id, params, _signal, _onUpdate, ctx) {
106
+ const runId = getRunId(ctx);
107
+ const rd = runDir(process.cwd(), runId);
108
+ const p = params as { risk_level?: string; material_fork?: boolean };
109
+ const route = await planSynthesisPath(rd, {
110
+ risk_level: p.risk_level,
111
+ material_fork: p.material_fork,
112
+ });
113
+ const spawns = await derivePlanRouteSpawns(rd, {
114
+ risk_level: p.risk_level,
115
+ material_fork: p.material_fork,
116
+ });
117
+ const sessionId = ctx.sessionManager.getSessionId();
118
+ captureHarnessEvent(sessionId, "harness_plan_route", {
119
+ run_id: runId,
120
+ route,
121
+ agents: spawns.agents,
122
+ });
123
+ return {
124
+ content: [
125
+ {
126
+ type: "text",
127
+ text: [
128
+ `route: ${route}`,
129
+ spawns.agents.length
130
+ ? `next_agents: ${spawns.agents.join(", ")}`
131
+ : "next_agents: (none — advance to debate or approval)",
132
+ ...spawns.rationale.map((r) => `- ${r}`),
133
+ ].join("\n"),
134
+ },
135
+ ],
136
+ details: spawns,
137
+ };
138
+ },
139
+ });
140
+ }