ultimate-pi 0.17.0 → 0.18.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 (110) hide show
  1. package/.agents/skills/harness-context/SKILL.md +13 -6
  2. package/.agents/skills/harness-debate-plan/SKILL.md +37 -20
  3. package/.agents/skills/harness-eval/SKILL.md +6 -21
  4. package/.agents/skills/harness-governor/SKILL.md +4 -3
  5. package/.agents/skills/harness-orchestration/SKILL.md +39 -51
  6. package/.agents/skills/harness-plan/SKILL.md +23 -12
  7. package/.agents/skills/harness-review/SKILL.md +52 -0
  8. package/.agents/skills/harness-sentrux-setup/SKILL.md +13 -1
  9. package/.agents/skills/harness-steer/SKILL.md +14 -0
  10. package/.pi/agents/harness/adversary.md +3 -10
  11. package/.pi/agents/harness/evaluator.md +3 -12
  12. package/.pi/agents/harness/executor.md +12 -14
  13. package/.pi/agents/harness/planning/decompose.md +7 -4
  14. package/.pi/agents/harness/planning/hypothesis-validator.md +2 -0
  15. package/.pi/agents/harness/planning/hypothesis.md +3 -1
  16. package/.pi/agents/harness/planning/plan-adversary.md +2 -0
  17. package/.pi/agents/harness/planning/plan-evaluator.md +2 -0
  18. package/.pi/agents/harness/planning/plan-synthesizer.md +25 -0
  19. package/.pi/agents/harness/planning/planning-context.md +48 -0
  20. package/.pi/agents/harness/planning/review-integrator.md +2 -0
  21. package/.pi/agents/harness/planning/scout-graphify.md +3 -1
  22. package/.pi/agents/harness/planning/scout-semantic.md +3 -1
  23. package/.pi/agents/harness/planning/scout-structure.md +3 -1
  24. package/.pi/agents/harness/planning/sprint-contract-auditor.md +2 -0
  25. package/.pi/agents/harness/sentrux-steward.md +51 -0
  26. package/.pi/extensions/00-posthog-network-bootstrap.ts +11 -0
  27. package/.pi/extensions/harness-live-widget.ts +27 -1
  28. package/.pi/extensions/harness-plan-approval.ts +62 -56
  29. package/.pi/extensions/harness-run-context.ts +541 -84
  30. package/.pi/extensions/harness-subagent-submit.ts +43 -10
  31. package/.pi/extensions/lib/harness-artifact-gate.ts +182 -0
  32. package/.pi/extensions/lib/harness-posthog.ts +9 -5
  33. package/.pi/extensions/lib/harness-spawn-topology.ts +188 -0
  34. package/.pi/extensions/lib/harness-subagent-auth.ts +1 -0
  35. package/.pi/extensions/lib/harness-subagent-policy.ts +23 -19
  36. package/.pi/extensions/lib/harness-subagent-precheck.ts +35 -9
  37. package/.pi/extensions/lib/harness-subagent-submit-pipeline.ts +66 -2
  38. package/.pi/extensions/lib/harness-subagent-submit-registry.ts +21 -3
  39. package/.pi/extensions/lib/harness-subagents-bridge.ts +7 -29
  40. package/.pi/extensions/lib/harness-subprocess-bootstrap.ts +73 -0
  41. package/.pi/extensions/lib/plan-approval/create-plan.ts +2 -3
  42. package/.pi/extensions/lib/plan-approval/resolve-disk.ts +102 -0
  43. package/.pi/extensions/lib/plan-approval/schema.ts +22 -8
  44. package/.pi/extensions/lib/plan-approval/types.ts +1 -1
  45. package/.pi/extensions/lib/plan-approval/validate.ts +2 -2
  46. package/.pi/extensions/lib/plan-approval-readiness.ts +241 -0
  47. package/.pi/extensions/lib/plan-debate-eligibility.ts +12 -5
  48. package/.pi/extensions/lib/plan-debate-gate.ts +22 -1
  49. package/.pi/extensions/lib/plan-debate-lanes.ts +32 -2
  50. package/.pi/extensions/lib/plan-review-gate.ts +8 -0
  51. package/.pi/extensions/lib/posthog-client.ts +76 -0
  52. package/.pi/extensions/policy-gate.ts +24 -19
  53. package/.pi/harness/agents.manifest.json +24 -16
  54. package/.pi/harness/corpus/cron.example +8 -0
  55. package/.pi/harness/corpus/graphify-kb-updater.config.json +159 -0
  56. package/.pi/harness/corpus/systemd/graphify-kb-updater.env.template +4 -0
  57. package/.pi/harness/corpus/systemd/graphify-kb-updater.service +17 -0
  58. package/.pi/harness/corpus/systemd/graphify-kb-updater.timer +11 -0
  59. package/.pi/harness/docs/adrs/0001-harness-constitution.md +2 -1
  60. package/.pi/harness/docs/adrs/0006-sentrux-dual-layer.md +7 -6
  61. package/.pi/harness/docs/adrs/0009-sentrux-rules-lifecycle.md +6 -1
  62. package/.pi/harness/docs/adrs/0031-harness-run-context.md +1 -1
  63. package/.pi/harness/docs/adrs/0032-harness-command-orchestration.md +7 -0
  64. package/.pi/harness/docs/adrs/0034-darwin-plan-research-pipeline.md +3 -3
  65. package/.pi/harness/docs/adrs/0036-implementation-research-and-selective-debate.md +8 -5
  66. package/.pi/harness/docs/adrs/0039-harness-post-run-review-gate.md +47 -0
  67. package/.pi/harness/docs/adrs/0040-practice-grounded-orchestration.md +40 -0
  68. package/.pi/harness/docs/adrs/0041-intelligent-planning-reconnaissance.md +39 -0
  69. package/.pi/harness/docs/adrs/0042-agent-native-orchestration.md +35 -0
  70. package/.pi/harness/docs/adrs/0043-path-first-harness-tools.md +38 -0
  71. package/.pi/harness/docs/adrs/0044-harness-steer-loop.md +36 -0
  72. package/.pi/harness/docs/adrs/README.md +10 -0
  73. package/.pi/harness/docs/graphify-kb-updater-runbook.md +157 -0
  74. package/.pi/harness/docs/practice-map.md +110 -0
  75. package/.pi/harness/env.harness.template +5 -3
  76. package/.pi/harness/evals/smoke/sentrux-stub.json +1 -1
  77. package/.pi/harness/evals/smoke/smoke-harness-plan.mjs +5 -2
  78. package/.pi/harness/specs/README.md +1 -1
  79. package/.pi/harness/specs/harness-run-context.schema.json +11 -0
  80. package/.pi/harness/specs/harness-spawn-context.schema.json +14 -0
  81. package/.pi/harness/specs/plan-execution-plan.schema.json +39 -1
  82. package/.pi/harness/specs/plan-packet.schema.json +4 -0
  83. package/.pi/harness/specs/plan-phase-status.schema.json +17 -0
  84. package/.pi/harness/specs/plan-phase-waiver.schema.json +25 -0
  85. package/.pi/harness/specs/plan-planning-context.schema.json +50 -0
  86. package/.pi/harness/specs/repair-brief.schema.json +45 -0
  87. package/.pi/harness/specs/review-outcome.schema.json +46 -0
  88. package/.pi/harness/specs/sentrux-manifest-proposal.schema.json +80 -0
  89. package/.pi/harness/specs/sentrux-signal.schema.json +43 -0
  90. package/.pi/harness/specs/steer-state.schema.json +20 -0
  91. package/.pi/lib/harness-context-mode-policy.ts +256 -0
  92. package/.pi/lib/harness-repair-brief.ts +145 -0
  93. package/.pi/lib/harness-run-context.ts +591 -32
  94. package/.pi/lib/harness-ui-state.ts +87 -9
  95. package/.pi/prompts/harness-auto.md +9 -9
  96. package/.pi/prompts/harness-critic.md +3 -30
  97. package/.pi/prompts/harness-eval.md +4 -37
  98. package/.pi/prompts/harness-plan.md +118 -54
  99. package/.pi/prompts/harness-review.md +150 -15
  100. package/.pi/prompts/harness-run.md +62 -10
  101. package/.pi/prompts/harness-sentrux-steward.md +55 -0
  102. package/.pi/prompts/harness-steer.md +30 -0
  103. package/.pi/scripts/graphify-kb-updater.mjs +358 -0
  104. package/.pi/scripts/harness-verify.mjs +22 -6
  105. package/.pi/scripts/harness-web-policy-guard.mjs +68 -0
  106. package/.pi/scripts/validate-plan-dag.mjs +3 -3
  107. package/AGENTS.md +1 -0
  108. package/CHANGELOG.md +11 -0
  109. package/package.json +5 -4
  110. package/.pi/prompts/git-sync.md +0 -124
@@ -6,10 +6,14 @@
6
6
  import { join } from "node:path";
7
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "@sinclair/typebox";
9
+ import { resolveGuardedRunDir } from "../lib/harness-subagent-submit-path.js";
9
10
  import { claimExtensionLoad } from "./lib/extension-load-guard.js";
10
11
  import { getHarnessPackageRoot } from "./lib/harness-paths.js";
11
12
  import { evaluateHarnessSubagentToolCall } from "./lib/harness-subagent-policy.js";
12
- import { executeSubmitPipeline } from "./lib/harness-subagent-submit-pipeline.js";
13
+ import {
14
+ executeSubmitPipeline,
15
+ loadSubmitDocument,
16
+ } from "./lib/harness-subagent-submit-pipeline.js";
13
17
  import { SUBMIT_TOOL_SPECS } from "./lib/harness-subagent-submit-registry.js";
14
18
 
15
19
  // @ts-expect-error pi extensions run as ESM
@@ -17,10 +21,18 @@ const MODULE_URL = import.meta.url;
17
21
 
18
22
  const DocumentSchema = Type.Object(
19
23
  {
20
- document: Type.Record(Type.String(), Type.Unknown(), {
21
- description:
22
- "Plan artifact fields (validated via plan-*.schema.json, persisted as canonical YAML on disk)",
23
- }),
24
+ document: Type.Optional(
25
+ Type.Record(Type.String(), Type.Unknown(), {
26
+ description:
27
+ "Artifact fields (deprecated when source_path is set; ADR 0043)",
28
+ }),
29
+ ),
30
+ source_path: Type.Optional(
31
+ Type.String({
32
+ description:
33
+ "Relative path under run dir, e.g. artifacts/.draft/decomposition.yaml",
34
+ }),
35
+ ),
24
36
  },
25
37
  { additionalProperties: false },
26
38
  );
@@ -118,21 +130,42 @@ export default function harnessSubagentSubmit(pi: ExtensionAPI) {
118
130
  isError: true,
119
131
  };
120
132
  }
121
- const document = (params as { document?: Record<string, unknown> })
122
- .document;
123
- if (!document || typeof document !== "object") {
133
+ const runResolved = await resolveGuardedRunDir({
134
+ projectRoot,
135
+ runId,
136
+ runDirEnv,
137
+ });
138
+ if (!runResolved.ok) {
124
139
  return {
125
- content: [{ type: "text", text: "document object is required" }],
140
+ content: [{ type: "text", text: runResolved.error }],
126
141
  details: {},
127
142
  isError: true,
128
143
  };
129
144
  }
145
+ const loaded = await loadSubmitDocument({
146
+ projectRoot,
147
+ runDir: runResolved.runDir,
148
+ document: (params as { document?: Record<string, unknown> }).document,
149
+ source_path: (params as { source_path?: string }).source_path,
150
+ });
151
+ if (!loaded.ok) {
152
+ return {
153
+ content: [
154
+ {
155
+ type: "text",
156
+ text: `Validation failed:\n${loaded.validation_errors.join("\n")}`,
157
+ },
158
+ ],
159
+ isError: true,
160
+ details: loaded,
161
+ };
162
+ }
130
163
  const result = await executeSubmitPipeline({
131
164
  projectRoot,
132
165
  specsDir,
133
166
  spec,
134
167
  agentId,
135
- document,
168
+ document: loaded.document,
136
169
  runId,
137
170
  runDirEnv,
138
171
  });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Content-aware gates for harness_artifact_ready (existence + minimal validity).
3
+ */
4
+
5
+ import { constants } from "node:fs";
6
+ import { access, readFile, stat } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { parse as parseYaml } from "yaml";
9
+ import { validateAgainstHarnessSchema } from "../../lib/harness-schema-validate.js";
10
+
11
+ export interface ArtifactGateResult {
12
+ ok: boolean;
13
+ errors: string[];
14
+ }
15
+
16
+ const ARTIFACT_SCHEMA: Record<string, string> = {
17
+ "artifacts/decomposition.yaml": "plan-decomposition-brief.schema.json",
18
+ "artifacts/hypothesis.yaml": "plan-hypothesis-brief.schema.json",
19
+ "artifacts/implementation-research.yaml":
20
+ "plan-implementation-research-brief.schema.json",
21
+ "artifacts/stack.yaml": "plan-stack-brief.schema.json",
22
+ "artifacts/planning-context.yaml": "plan-planning-context.schema.json",
23
+ "artifacts/scout-graphify.yaml": "plan-scout-findings.schema.json",
24
+ "artifacts/scout-structure.yaml": "plan-scout-findings.schema.json",
25
+ "artifacts/scout-semantic.yaml": "plan-scout-findings.schema.json",
26
+ "artifacts/eval-verdict.yaml": "eval-verdict.schema.json",
27
+ "artifacts/adversary-report.yaml": "adversary-report.schema.json",
28
+ };
29
+
30
+ const PREREQUISITE_ORDER: Record<string, string[]> = {
31
+ "artifacts/hypothesis.yaml": ["artifacts/decomposition.yaml"],
32
+ "artifacts/implementation-research.yaml": [
33
+ "artifacts/decomposition.yaml",
34
+ "artifacts/hypothesis.yaml",
35
+ ],
36
+ "artifacts/stack.yaml": [
37
+ "artifacts/decomposition.yaml",
38
+ "artifacts/hypothesis.yaml",
39
+ ],
40
+ };
41
+
42
+ async function fileExists(path: string): Promise<boolean> {
43
+ try {
44
+ await access(path, constants.R_OK);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function scoutStatusBad(doc: Record<string, unknown>): string | null {
52
+ const status = String(doc.status ?? "ok").toLowerCase();
53
+ if (status === "partial" || status === "failed" || status === "error") {
54
+ return `scout status is "${status}"`;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ export async function validateHarnessArtifactFile(
60
+ runRoot: string,
61
+ relPath: string,
62
+ specsDir: string,
63
+ ): Promise<ArtifactGateResult> {
64
+ const normalized = relPath.replace(/\\/g, "/");
65
+ const abs = join(runRoot, normalized);
66
+ const errors: string[] = [];
67
+
68
+ if (!(await fileExists(abs))) {
69
+ return { ok: false, errors: [`missing file: ${normalized}`] };
70
+ }
71
+
72
+ const st = await stat(abs);
73
+ if (st.size < 8) {
74
+ errors.push(`${normalized}: file too small (${st.size} bytes)`);
75
+ }
76
+
77
+ let doc: Record<string, unknown> | null = null;
78
+ try {
79
+ const raw = await readFile(abs, "utf-8");
80
+ if (!raw.trim()) {
81
+ errors.push(`${normalized}: empty file`);
82
+ } else {
83
+ doc = parseYaml(raw) as Record<string, unknown>;
84
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
85
+ errors.push(`${normalized}: root must be a YAML object`);
86
+ }
87
+ }
88
+ } catch (e) {
89
+ errors.push(
90
+ `${normalized}: invalid YAML (${e instanceof Error ? e.message : String(e)})`,
91
+ );
92
+ }
93
+
94
+ const schemaFile = ARTIFACT_SCHEMA[normalized];
95
+ if (doc && schemaFile) {
96
+ const validation = await validateAgainstHarnessSchema(
97
+ specsDir,
98
+ schemaFile,
99
+ doc,
100
+ );
101
+ if (!validation.ok) {
102
+ errors.push(
103
+ `${normalized}: schema validation failed — ${validation.errors.join("; ")}`,
104
+ );
105
+ }
106
+ }
107
+
108
+ if (doc && normalized.startsWith("artifacts/scout-")) {
109
+ const scoutErr = scoutStatusBad(doc);
110
+ if (scoutErr) {
111
+ errors.push(`${normalized}: ${scoutErr}`);
112
+ }
113
+ }
114
+
115
+ if (doc && normalized === "artifacts/planning-context.yaml") {
116
+ const scoutErr = scoutStatusBad(doc);
117
+ if (scoutErr) {
118
+ errors.push(`${normalized}: ${scoutErr}`);
119
+ }
120
+ const coverage = doc.coverage as Record<string, unknown> | undefined;
121
+ if (coverage && typeof coverage === "object") {
122
+ for (const lane of ["architecture", "structure"] as const) {
123
+ const laneDoc = coverage[lane] as Record<string, unknown> | undefined;
124
+ const laneStatus = String(laneDoc?.status ?? "").toLowerCase();
125
+ if (laneStatus !== "ok" && laneStatus !== "partial") {
126
+ errors.push(
127
+ `${normalized}: coverage.${lane}.status must be ok or partial (got "${laneStatus || "missing"}")`,
128
+ );
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ const prereqs = PREREQUISITE_ORDER[normalized] ?? [];
135
+ for (const prereq of prereqs) {
136
+ if (!(await fileExists(join(runRoot, prereq)))) {
137
+ errors.push(`${normalized}: prerequisite missing (${prereq})`);
138
+ }
139
+ }
140
+
141
+ return { ok: errors.length === 0, errors };
142
+ }
143
+
144
+ export async function validateHarnessArtifactPaths(
145
+ runRoot: string,
146
+ paths: string[],
147
+ specsDir: string,
148
+ ): Promise<{
149
+ ok: boolean;
150
+ present: string[];
151
+ missing: string[];
152
+ errors: string[];
153
+ }> {
154
+ const present: string[] = [];
155
+ const missing: string[] = [];
156
+ const errors: string[] = [];
157
+
158
+ for (const rel of paths) {
159
+ const normalized = rel.replace(/\\/g, "/");
160
+ const gate = await validateHarnessArtifactFile(
161
+ runRoot,
162
+ normalized,
163
+ specsDir,
164
+ );
165
+ if (gate.errors.some((e) => e.startsWith("missing file"))) {
166
+ missing.push(normalized);
167
+ continue;
168
+ }
169
+ if (!gate.ok) {
170
+ errors.push(...gate.errors);
171
+ continue;
172
+ }
173
+ present.push(normalized);
174
+ }
175
+
176
+ return {
177
+ ok: missing.length === 0 && errors.length === 0,
178
+ present,
179
+ missing,
180
+ errors,
181
+ };
182
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { PostHog } from "posthog-node";
9
+ import { getPostHogClientOptions } from "./posthog-client.js";
9
10
 
10
11
  export type HarnessPostHogEventName =
11
12
  | "harness_run_started"
@@ -48,9 +49,7 @@ function getClient(): PostHog | null {
48
49
  if (client) return client;
49
50
  const apiKey = process.env.POSTHOG_API_KEY?.trim();
50
51
  if (!apiKey) return null;
51
- client = new PostHog(apiKey, {
52
- host: process.env.POSTHOG_HOST?.trim() || "https://us.i.posthog.com",
53
- });
52
+ client = new PostHog(apiKey, getPostHogClientOptions());
54
53
  return client;
55
54
  }
56
55
 
@@ -109,6 +108,11 @@ export function captureHarnessEvent(
109
108
 
110
109
  export async function shutdownHarnessPostHog(): Promise<void> {
111
110
  if (!client) return;
112
- await client.shutdown();
113
- client = null;
111
+ try {
112
+ await client.shutdown();
113
+ } catch {
114
+ // Best-effort telemetry — avoid noisy flush errors when offline / WSL DNS broken.
115
+ } finally {
116
+ client = null;
117
+ }
114
118
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Harness subagent spawn topology rules (no vendor imports — testable in isolation).
3
+ */
4
+
5
+ import { constants } from "node:fs";
6
+ import { access } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import type { HarnessPhase } from "../../lib/harness-run-context.js";
9
+
10
+ export interface SpawnTopologyResult {
11
+ ok: boolean;
12
+ message?: string;
13
+ }
14
+
15
+ const DECOMPOSE_AGENT = "harness/planning/decompose";
16
+ const HYPOTHESIS_AGENT = "harness/planning/hypothesis";
17
+
18
+ const DEBATE_LANE_AGENTS = new Set([
19
+ "harness/planning/hypothesis-validator",
20
+ "harness/planning/plan-evaluator",
21
+ "harness/planning/plan-adversary",
22
+ "harness/planning/sprint-contract-auditor",
23
+ "harness/planning/review-integrator",
24
+ ]);
25
+
26
+ /** @deprecated Legacy tool-tied scouts — prefer parent tools + planning-context.yaml (ADR 0041). */
27
+ const LEGACY_SCOUT_AGENTS = new Set([
28
+ "harness/planning/scout-graphify",
29
+ "harness/planning/scout-structure",
30
+ "harness/planning/scout-semantic",
31
+ ]);
32
+
33
+ const PLANNING_CONTEXT_AGENT = "harness/planning/planning-context";
34
+
35
+ const PARALLEL_RESEARCH_AGENTS = new Set([
36
+ "harness/planning/implementation-researcher",
37
+ "harness/planning/stack-researcher",
38
+ ]);
39
+
40
+ function countInSet(names: string[], allowed: Set<string>): number {
41
+ return names.filter((n) => allowed.has(n)).length;
42
+ }
43
+
44
+ function isReconnaissanceAgent(name: string): boolean {
45
+ return LEGACY_SCOUT_AGENTS.has(name) || name === PLANNING_CONTEXT_AGENT;
46
+ }
47
+
48
+ async function decompositionReady(
49
+ projectRoot: string,
50
+ runId: string,
51
+ ): Promise<boolean> {
52
+ const path = join(
53
+ projectRoot,
54
+ ".pi",
55
+ "harness",
56
+ "runs",
57
+ runId,
58
+ "artifacts",
59
+ "decomposition.yaml",
60
+ );
61
+ try {
62
+ await access(path, constants.R_OK);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ export async function validateHarnessSpawnTopology(
70
+ names: string[],
71
+ phase: HarnessPhase,
72
+ opts?: {
73
+ parallelTaskCount?: number;
74
+ projectRoot?: string;
75
+ runId?: string | null;
76
+ },
77
+ ): Promise<SpawnTopologyResult> {
78
+ const taskCount =
79
+ opts?.parallelTaskCount ?? (names.length > 1 ? names.length : 1);
80
+
81
+ if (taskCount > 1) {
82
+ const hasDecompose = names.includes(DECOMPOSE_AGENT);
83
+ const hasHypothesis = names.includes(HYPOTHESIS_AGENT);
84
+ if (hasDecompose && hasHypothesis) {
85
+ return {
86
+ ok: false,
87
+ message:
88
+ "Cannot spawn decompose and hypothesis in the same parallel batch. " +
89
+ "Gate artifacts/decomposition.yaml, then spawn hypothesis sequentially.",
90
+ };
91
+ }
92
+
93
+ const debateCount = countInSet(names, DEBATE_LANE_AGENTS);
94
+ const debateNames = names.filter((n) => DEBATE_LANE_AGENTS.has(n));
95
+ const parallelProbePair =
96
+ debateCount === 2 &&
97
+ debateNames.includes("harness/planning/plan-evaluator") &&
98
+ debateNames.includes("harness/planning/plan-adversary");
99
+ if (debateCount > 1 && !parallelProbePair) {
100
+ return {
101
+ ok: false,
102
+ message: `Review Gate: spawn one debate lane agent per subagent call (got ${debateCount}: ${debateNames.join(", ")}). Exception: plan-evaluator ∥ plan-adversary for parallel_probes.`,
103
+ };
104
+ }
105
+
106
+ const legacyScouts = countInSet(names, LEGACY_SCOUT_AGENTS);
107
+ const planningContext = names.filter(
108
+ (n) => n === PLANNING_CONTEXT_AGENT,
109
+ ).length;
110
+ const research = countInSet(names, PARALLEL_RESEARCH_AGENTS);
111
+ const recon = legacyScouts + planningContext;
112
+
113
+ if (legacyScouts > 0 && planningContext > 0) {
114
+ return {
115
+ ok: false,
116
+ message:
117
+ "Do not mix legacy scout-* subagents with planning-context in one batch. " +
118
+ "Prefer parent tool use + planning-context.yaml, or a single planning-context subagent.",
119
+ };
120
+ }
121
+ if (legacyScouts > 3) {
122
+ return {
123
+ ok: false,
124
+ message:
125
+ "At most 3 legacy planning scouts per parallel batch (deprecated — use planning-context).",
126
+ };
127
+ }
128
+ if (planningContext > 1) {
129
+ return {
130
+ ok: false,
131
+ message: "At most one planning-context subagent per parallel batch.",
132
+ };
133
+ }
134
+
135
+ const otherHarness = names.filter(
136
+ (n) =>
137
+ n.startsWith("harness/") &&
138
+ !isReconnaissanceAgent(n) &&
139
+ !PARALLEL_RESEARCH_AGENTS.has(n) &&
140
+ !DEBATE_LANE_AGENTS.has(n) &&
141
+ n !== DECOMPOSE_AGENT &&
142
+ n !== HYPOTHESIS_AGENT,
143
+ );
144
+ if (
145
+ (recon > 0 && (research > 0 || otherHarness.length > 0)) ||
146
+ (research > 0 && otherHarness.length > 0)
147
+ ) {
148
+ return {
149
+ ok: false,
150
+ message:
151
+ "Parallel batches may include only one independent group: " +
152
+ "research (≤2 lanes), optional legacy scouts (≤3), optional single planning-context, " +
153
+ "or a single sequential lane agent.",
154
+ };
155
+ }
156
+ if (research > 2) {
157
+ return {
158
+ ok: false,
159
+ message:
160
+ "At most 2 research lanes (implementation-researcher, stack-researcher) per parallel batch.",
161
+ };
162
+ }
163
+ }
164
+
165
+ if (names.includes(HYPOTHESIS_AGENT) && opts?.projectRoot && opts?.runId) {
166
+ const ready = await decompositionReady(opts.projectRoot, opts.runId);
167
+ if (!ready) {
168
+ return {
169
+ ok: false,
170
+ message:
171
+ "Cannot spawn hypothesis before artifacts/decomposition.yaml exists. " +
172
+ "Complete decompose and harness_artifact_ready on decomposition first.",
173
+ };
174
+ }
175
+ }
176
+
177
+ if (phase === "plan") {
178
+ const mutating = names.filter((n) => n.startsWith("harness/executor"));
179
+ if (mutating.length > 0) {
180
+ return {
181
+ ok: false,
182
+ message: `Plan phase: cannot spawn mutating subagents (${mutating.join(", ")}).`,
183
+ };
184
+ }
185
+ }
186
+
187
+ return { ok: true };
188
+ }
@@ -51,6 +51,7 @@ const ROUTINE_PLANNING_AGENT_PATHS = new Set([
51
51
  "harness/planning/review-integrator",
52
52
  "harness/planning/hypothesis-validator",
53
53
  "harness/planning/sprint-contract-auditor",
54
+ "harness/planning/planning-context",
54
55
  "harness/planning/scout-structure",
55
56
  "harness/planning/scout-semantic",
56
57
  "harness/planning/decompose",
@@ -2,6 +2,11 @@
2
2
  * Per-agent tool policy for harness/* subagents (defense in depth with frontmatter).
3
3
  */
4
4
 
5
+ import {
6
+ evaluateContextModeMutation,
7
+ isMutatingBash,
8
+ } from "../../lib/harness-context-mode-policy.js";
9
+ import type { HarnessPhase } from "../../lib/harness-run-context.js";
5
10
  import {
6
11
  isSubmitToolName,
7
12
  SUBMIT_TOOLS_BY_AGENT,
@@ -40,21 +45,6 @@ const PLANNING_BASH_DENY_PATTERNS = [
40
45
  /\buv\s+tool\s+install\b.*cocoindex/i,
41
46
  ];
42
47
 
43
- const BASH_MUTATION_PATTERNS = [
44
- /\brm\s+-/i,
45
- /\bmv\s+/i,
46
- /\bcp\s+/i,
47
- /\btouch\s+/i,
48
- /\bmkdir\s+/i,
49
- /\btee\s+/i,
50
- /\bgit\s+(add|commit|push|reset|checkout|merge|rebase|cherry-pick|apply)\b/i,
51
- /\bnpm\s+(install|uninstall|ci)\b/i,
52
- /\bpnpm\s+(add|install|remove)\b/i,
53
- /\byarn\s+(add|install|remove)\b/i,
54
- /\bsed\s+-i\b/i,
55
- /\bperl\s+-i\b/i,
56
- ];
57
-
58
48
  const READ_ONLY_KINDS = new Set<HarnessAgentKind>([
59
49
  "planner",
60
50
  "evaluator",
@@ -95,10 +85,6 @@ export function classifyHarnessAgent(agentType: string): HarnessAgentKind {
95
85
  }
96
86
  }
97
87
 
98
- function isMutatingBash(command: string): boolean {
99
- return BASH_MUTATION_PATTERNS.some((pattern) => pattern.test(command));
100
- }
101
-
102
88
  export function isHarnessPackageAgent(agentType: string): boolean {
103
89
  return agentType.startsWith("harness/");
104
90
  }
@@ -207,6 +193,24 @@ export function evaluateHarnessSubagentToolCall(
207
193
  }
208
194
  }
209
195
 
196
+ const ctxPhase =
197
+ (harnessSubagentPhaseHint(agentType) as HarnessPhase | null) ?? "plan";
198
+ const ctxDecision = evaluateContextModeMutation(
199
+ toolName,
200
+ input ?? {},
201
+ ctxPhase,
202
+ { aborted: false, readOnlyAgent: true },
203
+ );
204
+ if (ctxDecision.blocked) {
205
+ return {
206
+ action: "block",
207
+ reason: ctxDecision.reason.replace(
208
+ /^policy-gate:/,
209
+ "harness-subagent-policy:",
210
+ ),
211
+ };
212
+ }
213
+
210
214
  return { action: "allow" };
211
215
  }
212
216
 
@@ -6,8 +6,11 @@ import {
6
6
  type AgentConfig,
7
7
  agentAllowsMutatingTools,
8
8
  } from "../../../vendor/pi-subagents/src/agents.js";
9
- import type { HarnessPhase } from "../../lib/harness-run-context.js";
10
- import { inferHarnessPhase } from "../../lib/harness-run-context.js";
9
+ import {
10
+ type HarnessPhase,
11
+ inferHarnessPhase,
12
+ } from "../../lib/harness-run-context.js";
13
+ import { validateHarnessSpawnTopology } from "./harness-spawn-topology.js";
11
14
  import { classifyHarnessAgent } from "./harness-subagent-policy.js";
12
15
 
13
16
  export interface SubagentTaskRef {
@@ -19,6 +22,11 @@ export interface PrecheckResult {
19
22
  message?: string;
20
23
  }
21
24
 
25
+ export interface PrecheckOptions {
26
+ projectRoot?: string;
27
+ runId?: string | null;
28
+ }
29
+
22
30
  function collectAgents(params: {
23
31
  agent?: string;
24
32
  tasks?: SubagentTaskRef[];
@@ -40,7 +48,7 @@ function resolveAgent(
40
48
  return agents.find((a) => a.name === name);
41
49
  }
42
50
 
43
- export function precheckHarnessSubagentSpawn(
51
+ export async function precheckHarnessSubagentSpawn(
44
52
  params: {
45
53
  agent?: string;
46
54
  tasks?: SubagentTaskRef[];
@@ -49,7 +57,8 @@ export function precheckHarnessSubagentSpawn(
49
57
  },
50
58
  agents: AgentConfig[],
51
59
  phase: HarnessPhase,
52
- ): PrecheckResult {
60
+ opts?: PrecheckOptions,
61
+ ): Promise<PrecheckResult> {
53
62
  const names = collectAgents(params);
54
63
  const mutating = names.filter((n) => {
55
64
  const cfg = resolveAgent(agents, n);
@@ -67,7 +76,17 @@ export function precheckHarnessSubagentSpawn(
67
76
  };
68
77
  }
69
78
 
70
- if ((params.tasks?.length ?? 0) > 1 && mutating.length > 1) {
79
+ const parallelEvalAdversary =
80
+ (params.tasks?.length ?? 0) === 2 &&
81
+ params.tasks?.some((t) => t.agent === "harness/evaluator") &&
82
+ params.tasks?.some((t) => t.agent === "harness/adversary") &&
83
+ phase === "evaluate";
84
+
85
+ if (
86
+ (params.tasks?.length ?? 0) > 1 &&
87
+ mutating.length > 1 &&
88
+ !parallelEvalAdversary
89
+ ) {
71
90
  return {
72
91
  ok: false,
73
92
  message:
@@ -76,12 +95,19 @@ export function precheckHarnessSubagentSpawn(
76
95
  };
77
96
  }
78
97
 
98
+ const parallelTaskCount = params.tasks?.length ?? (params.agent ? 1 : 0);
99
+ const topology = await validateHarnessSpawnTopology(names, phase, {
100
+ parallelTaskCount,
101
+ projectRoot: opts?.projectRoot,
102
+ runId: opts?.runId,
103
+ });
104
+ if (!topology.ok) {
105
+ return topology;
106
+ }
107
+
79
108
  for (const name of names) {
80
109
  if (!name.startsWith("harness/")) continue;
81
- const kind = classifyHarnessAgent(name);
82
- if (kind === "planner" && phase !== "plan") {
83
- // allowed — planning agents can run in plan only ideally
84
- }
110
+ classifyHarnessAgent(name);
85
111
  }
86
112
 
87
113
  return { ok: true };