ultimate-pi 0.10.0 → 0.11.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 (53) hide show
  1. package/.agents/skills/harness-decisions/SKILL.md +3 -3
  2. package/.agents/skills/harness-orchestration/SKILL.md +19 -11
  3. package/.agents/skills/harness-plan/SKILL.md +15 -9
  4. package/.pi/agents/harness/planner.md +6 -47
  5. package/.pi/agents/harness/planning/decompose.md +84 -0
  6. package/.pi/agents/harness/planning/hypothesis-eval.md +59 -0
  7. package/.pi/agents/harness/planning/hypothesis.md +90 -0
  8. package/.pi/agents/harness/planning/plan-adversary.md +50 -0
  9. package/.pi/agents/harness/planning/planner.md +20 -0
  10. package/.pi/agents/harness/planning/scout-graphify.md +48 -0
  11. package/.pi/agents/harness/planning/scout-semantic.md +42 -0
  12. package/.pi/agents/harness/planning/scout-structure.md +44 -0
  13. package/.pi/extensions/harness-ask-user.ts +5 -0
  14. package/.pi/extensions/harness-live-widget.ts +48 -28
  15. package/.pi/extensions/harness-plan-approval.ts +192 -24
  16. package/.pi/extensions/harness-run-context.ts +24 -15
  17. package/.pi/extensions/harness-subagents.ts +8 -3
  18. package/.pi/extensions/harness-web-tools.ts +2 -0
  19. package/.pi/extensions/lib/extension-load-guard.ts +39 -0
  20. package/.pi/extensions/lib/harness-subagents/harness-subagent-policy.ts +33 -5
  21. package/.pi/extensions/lib/harness-subagents/parent-harness-ui-bridge.ts +2 -171
  22. package/.pi/extensions/lib/harness-subagents/parent-harness-ui-hooks.ts +18 -0
  23. package/.pi/extensions/lib/harness-subagents/spawn-policy.ts +1 -5
  24. package/.pi/extensions/lib/harness-subagents/vendored/agent-runner.ts +0 -18
  25. package/.pi/extensions/lib/harness-subagents/vendored/index.ts +4 -36
  26. package/.pi/extensions/lib/harness-subagents/vendored/ui/agent-widget.ts +2 -0
  27. package/.pi/extensions/lib/plan-approval/create-plan.ts +5 -0
  28. package/.pi/extensions/lib/plan-approval/dialog.ts +231 -147
  29. package/.pi/extensions/lib/plan-approval/plan-review.ts +393 -0
  30. package/.pi/extensions/lib/plan-approval/schema.ts +16 -1
  31. package/.pi/extensions/lib/plan-approval/types.ts +10 -0
  32. package/.pi/extensions/lib/plan-approval/validate.ts +2 -0
  33. package/.pi/extensions/policy-gate.ts +1 -1
  34. package/.pi/extensions/ultimate-pi-vcc.ts +5 -0
  35. package/.pi/harness/agents.manifest.json +114 -82
  36. package/.pi/harness/docs/adrs/0032-harness-command-orchestration.md +3 -3
  37. package/.pi/harness/docs/adrs/0033-parent-orchestrated-planning.md +34 -0
  38. package/.pi/harness/docs/adrs/0034-darwin-plan-research-pipeline.md +41 -0
  39. package/.pi/harness/docs/adrs/README.md +2 -0
  40. package/.pi/harness/specs/README.md +1 -1
  41. package/.pi/harness/specs/harness-spawn-context.schema.json +2 -1
  42. package/.pi/harness/specs/plan-adversary-brief.schema.json +45 -0
  43. package/.pi/harness/specs/plan-decomposition-brief.schema.json +108 -0
  44. package/.pi/harness/specs/plan-hypothesis-brief.schema.json +96 -0
  45. package/.pi/harness/specs/plan-hypothesis-eval.schema.json +61 -0
  46. package/.pi/lib/harness-run-context.ts +12 -0
  47. package/.pi/prompts/harness-auto.md +1 -1
  48. package/.pi/prompts/harness-plan.md +116 -20
  49. package/.pi/prompts/harness-setup.md +1 -1
  50. package/.pi/scripts/harness-resolve-up-pkg.mjs +13 -0
  51. package/CHANGELOG.md +18 -0
  52. package/biome.json +4 -1
  53. package/package.json +2 -2
@@ -329,6 +329,52 @@ export default function harnessLiveWidget(pi: ExtensionAPI) {
329
329
  let component: HarnessWidgetComponent | null = null;
330
330
  let refreshQueued = false;
331
331
  let lastRenderHash = "";
332
+ let mountCtx: ExtensionContext | null = null;
333
+
334
+ function mountHarnessWidget(ctx: ExtensionContext): void {
335
+ if (!ctx.hasUI) return;
336
+ const state = stateStore.refresh(ctx);
337
+ const inFlight: InFlightState = { toolCount: 0, lastToolName: null };
338
+ lastRenderHash = computeRenderHash(state, inFlight);
339
+
340
+ ctx.ui.setWidget(
341
+ "harness-live",
342
+ (tui, theme) => {
343
+ widgetMounted = true;
344
+ tuiHandle = tui;
345
+ component = new HarnessWidgetComponent(
346
+ stateStore.snapshot(),
347
+ inFlight,
348
+ theme,
349
+ );
350
+ return {
351
+ render(width: number): string[] {
352
+ component?.setTheme(theme);
353
+ return component?.render(width) ?? [];
354
+ },
355
+ invalidate(): void {
356
+ component?.invalidate();
357
+ },
358
+ };
359
+ },
360
+ { placement: "aboveEditor" },
361
+ );
362
+ updateStatusFallback(ctx, state);
363
+ }
364
+
365
+ function remountHarnessLiveWidget(ctx: ExtensionContext): void {
366
+ if (!ctx.hasUI || !widgetMounted) return;
367
+ ctx.ui.setWidget("harness-live", undefined);
368
+ mountHarnessWidget(ctx);
369
+ }
370
+
371
+ pi.events.on("subagents:agents-widget-mounted", () => {
372
+ if (mountCtx) remountHarnessLiveWidget(mountCtx);
373
+ });
374
+
375
+ pi.events.on("plan-approval:mounted", () => {
376
+ if (mountCtx) remountHarnessLiveWidget(mountCtx);
377
+ });
332
378
 
333
379
  function updateStatusFallback(
334
380
  ctx: ExtensionContext,
@@ -385,34 +431,8 @@ export default function harnessLiveWidget(pi: ExtensionAPI) {
385
431
  }
386
432
 
387
433
  pi.on("session_start", (_event, ctx) => {
388
- if (!ctx.hasUI) return;
389
- const state = stateStore.refresh(ctx);
390
- const inFlight: InFlightState = { toolCount: 0, lastToolName: null };
391
- lastRenderHash = computeRenderHash(state, inFlight);
392
-
393
- ctx.ui.setWidget(
394
- "harness-live",
395
- (tui, theme) => {
396
- widgetMounted = true;
397
- tuiHandle = tui;
398
- component = new HarnessWidgetComponent(
399
- stateStore.snapshot(),
400
- inFlight,
401
- theme,
402
- );
403
- return {
404
- render(width: number): string[] {
405
- component?.setTheme(theme);
406
- return component?.render(width) ?? [];
407
- },
408
- invalidate(): void {
409
- component?.invalidate();
410
- },
411
- };
412
- },
413
- { placement: "aboveEditor" },
414
- );
415
- updateStatusFallback(ctx, state);
434
+ mountCtx = ctx;
435
+ mountHarnessWidget(ctx);
416
436
  });
417
437
 
418
438
  pi.on("context", (_event, ctx) => {
@@ -4,13 +4,25 @@
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { Text } from "@earendil-works/pi-tui";
7
+ import { Type } from "@sinclair/typebox";
8
+ import type { PlanPacketLike } from "../lib/harness-run-context.js";
7
9
  import {
8
10
  appendPlanApprovalIfNew,
9
11
  getLatestRunContext,
12
+ hasPlanUserApproval,
10
13
  parsePlanApprovalFromMessage,
14
+ planPacketSummary,
11
15
  } from "../lib/harness-run-context.js";
16
+ import { claimExtensionLoad } from "./lib/extension-load-guard.js";
17
+ import {
18
+ CREATE_PLAN_GUIDELINES,
19
+ CREATE_PLAN_SNIPPET,
20
+ executeCreatePlan,
21
+ formatCreatePlanResultText,
22
+ } from "./lib/plan-approval/create-plan.js";
12
23
  import { runPlanApprovalDialog } from "./lib/plan-approval/dialog.js";
13
24
  import { runPlanApprovalFallback } from "./lib/plan-approval/fallback.js";
25
+ import { writePlanReviewMarkdown } from "./lib/plan-approval/plan-review.js";
14
26
  import {
15
27
  renderApprovePlanCall,
16
28
  renderApprovePlanResult,
@@ -31,33 +43,50 @@ import {
31
43
  validateApprovePlanParams,
32
44
  } from "./lib/plan-approval/validate.js";
33
45
 
46
+ // @ts-expect-error pi extensions run as ESM
47
+ const MODULE_URL = import.meta.url;
48
+
49
+ const CreatePlanParamsSchema = Type.Object({
50
+ plan_packet: Type.Object(
51
+ {},
52
+ {
53
+ description:
54
+ "Approved PlanPacket to persist (same object as approve_plan).",
55
+ },
56
+ ),
57
+ });
58
+
34
59
  export default function harnessPlanApproval(pi: ExtensionAPI) {
35
- pi.registerMessageRenderer("harness-plan-draft", (message, _options, theme) => {
36
- const data = message.details as
37
- | {
38
- plan_packet?: unknown;
39
- human_summary?: string | null;
40
- }
41
- | undefined;
42
- if (!data?.plan_packet) return undefined;
43
- const lines = renderHarnessPlanDraft(
44
- {
45
- plan_packet: data.plan_packet as Parameters<
46
- typeof renderHarnessPlanDraft
47
- >[0]["plan_packet"],
48
- human_summary: data.human_summary,
49
- },
50
- 80,
51
- theme,
52
- );
53
- return new Text(lines.join("\n"), 0, 0);
54
- });
60
+ if (!claimExtensionLoad("harness-plan-approval", MODULE_URL)) return;
61
+ pi.registerMessageRenderer(
62
+ "harness-plan-draft",
63
+ (message, _options, theme) => {
64
+ const data = message.details as
65
+ | {
66
+ plan_packet?: unknown;
67
+ human_summary?: string | null;
68
+ }
69
+ | undefined;
70
+ if (!data?.plan_packet) return undefined;
71
+ const lines = renderHarnessPlanDraft(
72
+ {
73
+ plan_packet: data.plan_packet as Parameters<
74
+ typeof renderHarnessPlanDraft
75
+ >[0]["plan_packet"],
76
+ human_summary: data.human_summary,
77
+ },
78
+ 80,
79
+ theme,
80
+ );
81
+ return new Text(lines.join("\n"), 0, 0);
82
+ },
83
+ );
55
84
 
56
85
  pi.registerTool({
57
86
  name: "approve_plan",
58
87
  label: "Approve Plan",
59
88
  description:
60
- "Present a PlanPacket for user approval with a scrollable plan view. Planners should prefer the subagent bridge; this registers the tool on parent sessions for non-interactive fallback.",
89
+ "Present a PlanPacket for user approval with a scrollable plan view. Parent /harness-plan orchestrator calls this after decomposition, hypothesis, and parallel reviews.",
61
90
  promptSnippet: PROMPT_SNIPPET,
62
91
  promptGuidelines: PROMPT_GUIDELINES,
63
92
  parameters: ApprovePlanParamsSchema,
@@ -76,25 +105,74 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
76
105
  };
77
106
  }
78
107
 
108
+ const entries = ctx.sessionManager.getEntries();
109
+ if (
110
+ hasPlanUserApproval(entries, {
111
+ sincePlanCommand: true,
112
+ planId: validated.plan_packet.plan_id ?? null,
113
+ })
114
+ ) {
115
+ const planId = String(validated.plan_packet.plan_id ?? "plan");
116
+ return {
117
+ content: [
118
+ {
119
+ type: "text",
120
+ text: `Plan ${planId} already approved in this harness run. Proceed with /harness-run.`,
121
+ },
122
+ ],
123
+ details: {
124
+ plan_packet: validated.plan_packet,
125
+ options: validated.options,
126
+ response: {
127
+ kind: "selection",
128
+ selections: ["Approve"],
129
+ },
130
+ cancelled: false,
131
+ },
132
+ };
133
+ }
134
+
79
135
  const planId = String(validated.plan_packet.plan_id ?? "plan");
80
136
  const summary =
81
137
  validated.human_summary?.trim() ||
82
138
  `Plan ${planId} — pending your approval`;
139
+ const runCtx = getLatestRunContext(entries);
140
+ const projectRoot = process.cwd();
141
+ const reviewPath = await writePlanReviewMarkdown(
142
+ projectRoot,
143
+ runCtx,
144
+ validated.plan_packet,
145
+ {
146
+ human_summary: validated.human_summary,
147
+ research_brief: validated.research_brief,
148
+ status: "draft",
149
+ },
150
+ );
151
+ const draftContent =
152
+ reviewPath != null
153
+ ? `${summary}\nEditor review: ${reviewPath}`
154
+ : summary;
83
155
  pi.sendMessage({
84
156
  customType: "harness-plan-draft",
85
- content: summary,
157
+ content: draftContent,
86
158
  display: true,
87
159
  details: {
88
160
  schema_version: "1.0.0",
89
161
  plan_packet: validated.plan_packet,
90
162
  human_summary: validated.human_summary ?? null,
163
+ research_brief: validated.research_brief ?? null,
164
+ plan_review_path: reviewPath,
91
165
  shown_at: new Date().toISOString(),
92
166
  },
93
167
  });
94
168
 
95
169
  let outcome: PlanApprovalDialogResult;
96
170
  if (ctx.hasUI) {
97
- outcome = await runPlanApprovalDialog(ctx.ui, validated);
171
+ outcome = await runPlanApprovalDialog(ctx.ui, validated, {
172
+ onMounted: () => {
173
+ pi.events.emit("plan-approval:mounted", {});
174
+ },
175
+ });
98
176
  } else {
99
177
  outcome = await runPlanApprovalFallback(ctx.ui, validated);
100
178
  }
@@ -109,7 +187,6 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
109
187
  details,
110
188
  });
111
189
  if (approval) {
112
- const entries = ctx.sessionManager.getEntries();
113
190
  const runCtx = getLatestRunContext(entries);
114
191
  appendPlanApprovalIfNew(
115
192
  (type, data) => pi.appendEntry(type, data),
@@ -119,6 +196,23 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
119
196
  );
120
197
  }
121
198
 
199
+ const approved =
200
+ !outcome.cancelled &&
201
+ outcome.response?.kind === "selection" &&
202
+ /^approve/i.test(outcome.response.selections[0] ?? "");
203
+ if (approved && runCtx) {
204
+ await writePlanReviewMarkdown(
205
+ projectRoot,
206
+ runCtx,
207
+ validated.plan_packet,
208
+ {
209
+ human_summary: validated.human_summary,
210
+ research_brief: validated.research_brief,
211
+ status: "approved",
212
+ },
213
+ );
214
+ }
215
+
122
216
  const text = formatApprovePlanResultText(
123
217
  outcome.response,
124
218
  outcome.cancelled,
@@ -137,4 +231,78 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
137
231
  return renderApprovePlanResult(result, options, theme);
138
232
  },
139
233
  });
234
+
235
+ pi.registerTool({
236
+ name: "create_plan",
237
+ label: "Create Plan",
238
+ description:
239
+ "Write the approved PlanPacket to plan-packet.json for this harness run. Call only after approve_plan (Approve). Do not use write/edit.",
240
+ promptSnippet: CREATE_PLAN_SNIPPET,
241
+ promptGuidelines: CREATE_PLAN_GUIDELINES,
242
+ parameters: CreatePlanParamsSchema,
243
+
244
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
245
+ const validated = validateApprovePlanParams(params as ApprovePlanParams);
246
+ if (typeof validated === "string") {
247
+ return {
248
+ content: [{ type: "text", text: validated }],
249
+ details: { error: validated },
250
+ isError: true,
251
+ };
252
+ }
253
+
254
+ const entries = ctx.sessionManager.getEntries();
255
+ const runCtx = getLatestRunContext(entries);
256
+ const projectRoot = process.cwd();
257
+ const result = await executeCreatePlan(validated.plan_packet, {
258
+ projectRoot,
259
+ getParentEntries: () => entries,
260
+ getSubagentEntries: () => entries,
261
+ getParentRunContext: () => runCtx,
262
+ onCommitted: (updated, packet, planPath) => {
263
+ pi.appendEntry("harness-run-context", updated);
264
+ pi.appendEntry(
265
+ "harness-plan-packet",
266
+ planPacketSummary(packet, planPath, "ready"),
267
+ );
268
+ },
269
+ });
270
+
271
+ const text = formatCreatePlanResultText(result);
272
+ return {
273
+ content: [{ type: "text", text }],
274
+ details: result.ok
275
+ ? { plan_path: result.planPath, plan_id: result.planId }
276
+ : { error: result.error },
277
+ isError: !result.ok,
278
+ };
279
+ },
280
+
281
+ renderCall(args, theme) {
282
+ const packet = (args as { plan_packet?: PlanPacketLike }).plan_packet;
283
+ const id = packet?.plan_id ?? "?";
284
+ return new Text(theme.fg("accent", `create_plan: ${id}`), 0, 0);
285
+ },
286
+
287
+ renderResult(result, _options, theme) {
288
+ const details = result.details as
289
+ | { plan_path?: string; error?: string }
290
+ | undefined;
291
+ if (details?.error) {
292
+ return new Text(
293
+ theme.fg("error", details.error ?? "create_plan failed"),
294
+ 0,
295
+ 0,
296
+ );
297
+ }
298
+ return new Text(
299
+ theme.fg(
300
+ "success",
301
+ `Wrote ${details?.plan_path ?? "plan-packet.json"}`,
302
+ ),
303
+ 0,
304
+ 0,
305
+ );
306
+ },
307
+ });
140
308
  }
@@ -671,20 +671,29 @@ export default function harnessRunContext(pi: ExtensionAPI) {
671
671
  });
672
672
 
673
673
  pi.on("tool_call", async (event, ctx) => {
674
- if (event.toolName === "ask_user" && activeCtx?.plan_packet_path) {
675
- const input = event.input as {
676
- question?: string;
677
- options?: unknown[];
678
- };
679
- if (
680
- isPlanApprovalAskUser(input) &&
681
- hasPlanUserApproval(getEntries(ctx), { sincePlanCommand: true })
682
- ) {
683
- return {
684
- block: true,
685
- reason:
686
- "harness-run-context: plan already approved via planner subagent; do not call ask_user for plan approval in the parent session.",
687
- };
674
+ if (activeCtx?.plan_packet_path) {
675
+ const entries = getEntries(ctx);
676
+ if (hasPlanUserApproval(entries, { sincePlanCommand: true })) {
677
+ if (event.toolName === "approve_plan") {
678
+ return {
679
+ block: true,
680
+ reason:
681
+ "harness-run-context: plan already approved via planner subagent; do not call approve_plan again in the parent session.",
682
+ };
683
+ }
684
+ if (event.toolName === "ask_user") {
685
+ const input = event.input as {
686
+ question?: string;
687
+ options?: unknown[];
688
+ };
689
+ if (isPlanApprovalAskUser(input)) {
690
+ return {
691
+ block: true,
692
+ reason:
693
+ "harness-run-context: plan already approved via planner subagent; do not call ask_user for plan approval in the parent session.",
694
+ };
695
+ }
696
+ }
688
697
  }
689
698
  }
690
699
  if (!activeCtx?.plan_packet_path) return undefined;
@@ -807,7 +816,7 @@ export default function harnessRunContext(pi: ExtensionAPI) {
807
816
  })
808
817
  ) {
809
818
  const msg =
810
- "Plan commit blocked: no user approval recorded. Approve via ask_user in the planner subagent first.";
819
+ "Plan commit blocked: no user approval recorded. Approve via approve_plan in this session first.";
811
820
  if (ctx.hasUI) ctx.ui.notify(msg, "warning");
812
821
  return;
813
822
  }
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * harness-subagents — package-resolved agents, blackboard, observation-bus handoffs.
3
3
  */
4
+
5
+ import { claimExtensionLoad } from "./lib/extension-load-guard.js";
4
6
  import { getHarnessPackageRoot } from "./lib/harness-paths.js";
5
7
  import { createHarnessSubagentsExtension } from "./lib/harness-subagents/vendored/index.js";
6
8
 
7
- export default createHarnessSubagentsExtension(
8
- getHarnessPackageRoot(import.meta.url),
9
- );
9
+ // @ts-expect-error pi extensions run as ESM
10
+ const MODULE_URL = import.meta.url;
11
+
12
+ export default claimExtensionLoad("harness-subagents", MODULE_URL)
13
+ ? createHarnessSubagentsExtension(getHarnessPackageRoot(MODULE_URL))
14
+ : () => {};
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "@sinclair/typebox";
7
+ import { claimExtensionLoad } from "./lib/extension-load-guard.js";
7
8
  import {
8
9
  harnessWebContextLine,
9
10
  readTextExcerpt,
@@ -97,6 +98,7 @@ function sessionCwd(ctx: { cwd?: string }): string {
97
98
  }
98
99
 
99
100
  export default function harnessWebTools(pi: ExtensionAPI) {
101
+ if (!claimExtensionLoad("harness-web-tools", MODULE_URL)) return;
100
102
  pi.on("before_agent_start", async (event) => {
101
103
  return {
102
104
  systemPrompt: `${event.systemPrompt}\n\n${harnessWebContextLine()}`,
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const LOAD_GUARD_KEY = Symbol.for("ultimate-pi.extension-load-guard");
6
+
7
+ type LoadGuardRegistry = Set<string>;
8
+
9
+ function getRegistry(): LoadGuardRegistry {
10
+ const state = globalThis as typeof globalThis & {
11
+ [LOAD_GUARD_KEY]?: LoadGuardRegistry;
12
+ };
13
+ if (!state[LOAD_GUARD_KEY]) {
14
+ state[LOAD_GUARD_KEY] = new Set<string>();
15
+ }
16
+ return state[LOAD_GUARD_KEY];
17
+ }
18
+
19
+ function isSourceRepo(): boolean {
20
+ try {
21
+ const pkg = JSON.parse(
22
+ readFileSync(join(process.cwd(), "package.json"), "utf8"),
23
+ ) as { name?: string };
24
+ return pkg.name === "ultimate-pi";
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ export function claimExtensionLoad(key: string, moduleUrl: string): boolean {
31
+ const registry = getRegistry();
32
+ const modulePath = fileURLToPath(moduleUrl);
33
+ if (modulePath.includes("/node_modules/ultimate-pi/") && isSourceRepo()) {
34
+ return false;
35
+ }
36
+ if (registry.has(key)) return false;
37
+ registry.add(key);
38
+ return true;
39
+ }
@@ -20,6 +20,15 @@ export type HarnessAgentKind =
20
20
 
21
21
  const MUTATING_TOOLS = new Set(["write", "edit"]);
22
22
 
23
+ const PLANNING_BASH_DENY_PATTERNS = [
24
+ /\bgraphify\s+update\b/i,
25
+ /\bgraphify\s+extract\b/i,
26
+ /\bgraphify\s+install\b/i,
27
+ /\bpip\s+install\b/i,
28
+ /\buv\s+tool\s+install\b/i,
29
+ /\bnpm\s+install\b/i,
30
+ ];
31
+
23
32
  const BASH_MUTATION_PATTERNS = [
24
33
  /\brm\s+-/i,
25
34
  /\bmv\s+/i,
@@ -45,8 +54,16 @@ const READ_ONLY_KINDS = new Set<HarnessAgentKind>([
45
54
  "meta",
46
55
  ]);
47
56
 
57
+ export function isHarnessPlanningAgent(agentType: string): boolean {
58
+ const id = agentType.replace(/^harness\//, "");
59
+ return id === "planner" || id.startsWith("planning/");
60
+ }
61
+
48
62
  export function classifyHarnessAgent(agentType: string): HarnessAgentKind {
49
63
  const id = agentType.replace(/^harness\//, "");
64
+ if (id.startsWith("planning/") || id === "planner") {
65
+ return "planner";
66
+ }
50
67
  switch (id) {
51
68
  case "planner":
52
69
  return "planner";
@@ -96,13 +113,10 @@ export function evaluateHarnessSubagentToolCall(
96
113
  return { action: "allow" };
97
114
  }
98
115
 
99
- if (toolName === "create_plan") {
100
- if (kind === "planner") {
101
- return { action: "allow" };
102
- }
116
+ if (toolName === "create_plan" || toolName === "approve_plan") {
103
117
  return {
104
118
  action: "block",
105
- reason: `harness-subagent-policy: create_plan is only for harness/planner.`,
119
+ reason: `harness-subagent-policy: ${toolName} is parent-orchestrator only (not available in subagents).`,
106
120
  };
107
121
  }
108
122
 
@@ -121,6 +135,17 @@ export function evaluateHarnessSubagentToolCall(
121
135
  reason: `harness-subagent-policy: mutating bash blocked for harness/${kind}.`,
122
136
  };
123
137
  }
138
+ if (
139
+ command &&
140
+ isHarnessPlanningAgent(agentType) &&
141
+ PLANNING_BASH_DENY_PATTERNS.some((p) => p.test(command))
142
+ ) {
143
+ return {
144
+ action: "block",
145
+ reason:
146
+ "harness-subagent-policy: planning scouts may use read-only graphify/sg/ck commands only.",
147
+ };
148
+ }
124
149
  }
125
150
 
126
151
  return { action: "allow" };
@@ -128,6 +153,9 @@ export function evaluateHarnessSubagentToolCall(
128
153
 
129
154
  /** Policy phase hint seeded into subagent system prompt appendix when extensions load policy-gate. */
130
155
  export function harnessSubagentPhaseHint(agentType: string): string | null {
156
+ if (isHarnessPlanningAgent(agentType)) {
157
+ return "plan";
158
+ }
131
159
  const kind = classifyHarnessAgent(agentType);
132
160
  switch (kind) {
133
161
  case "planner":