ultimate-pi 0.16.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 (137) 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 +4 -2
  16. package/.pi/agents/harness/planning/implementation-researcher.md +1 -1
  17. package/.pi/agents/harness/planning/plan-adversary.md +2 -0
  18. package/.pi/agents/harness/planning/plan-evaluator.md +2 -0
  19. package/.pi/agents/harness/planning/plan-synthesizer.md +25 -0
  20. package/.pi/agents/harness/planning/planning-context.md +48 -0
  21. package/.pi/agents/harness/planning/review-integrator.md +2 -0
  22. package/.pi/agents/harness/planning/scout-graphify.md +3 -1
  23. package/.pi/agents/harness/planning/scout-semantic.md +3 -1
  24. package/.pi/agents/harness/planning/scout-structure.md +3 -1
  25. package/.pi/agents/harness/planning/sprint-contract-auditor.md +2 -0
  26. package/.pi/agents/harness/sentrux-steward.md +51 -0
  27. package/.pi/extensions/00-posthog-network-bootstrap.ts +11 -0
  28. package/.pi/extensions/harness-debate-tools.ts +12 -3
  29. package/.pi/extensions/harness-live-widget.ts +27 -1
  30. package/.pi/extensions/harness-plan-approval.ts +62 -56
  31. package/.pi/extensions/harness-run-context.ts +553 -84
  32. package/.pi/extensions/harness-subagent-submit.ts +43 -33
  33. package/.pi/extensions/harness-telemetry.ts +29 -4
  34. package/.pi/extensions/lib/debate-bus-core.ts +15 -9
  35. package/.pi/extensions/lib/harness-artifact-gate.ts +182 -0
  36. package/.pi/extensions/lib/harness-posthog.ts +9 -5
  37. package/.pi/extensions/lib/harness-spawn-topology.ts +188 -0
  38. package/.pi/extensions/lib/harness-subagent-auth.ts +105 -19
  39. package/.pi/extensions/lib/harness-subagent-policy.ts +37 -19
  40. package/.pi/extensions/lib/harness-subagent-precheck.ts +35 -9
  41. package/.pi/extensions/lib/harness-subagent-submit-pipeline.ts +66 -2
  42. package/.pi/extensions/lib/harness-subagent-submit-registry.ts +21 -3
  43. package/.pi/extensions/lib/harness-subagents-bridge.ts +91 -28
  44. package/.pi/extensions/lib/harness-subprocess-bootstrap.ts +73 -0
  45. package/.pi/extensions/lib/plan-approval/create-plan.ts +2 -3
  46. package/.pi/extensions/lib/plan-approval/resolve-disk.ts +102 -0
  47. package/.pi/extensions/lib/plan-approval/schema.ts +22 -8
  48. package/.pi/extensions/lib/plan-approval/types.ts +1 -1
  49. package/.pi/extensions/lib/plan-approval/validate.ts +2 -2
  50. package/.pi/extensions/lib/plan-approval-readiness.ts +241 -0
  51. package/.pi/extensions/lib/plan-debate-eligibility.ts +67 -7
  52. package/.pi/extensions/lib/plan-debate-focus.ts +21 -9
  53. package/.pi/extensions/lib/plan-debate-gate.ts +101 -17
  54. package/.pi/extensions/lib/plan-debate-lanes.ts +57 -3
  55. package/.pi/extensions/lib/plan-debate-round-status.ts +18 -7
  56. package/.pi/extensions/lib/plan-messenger.ts +4 -0
  57. package/.pi/extensions/lib/plan-review-gate.ts +59 -0
  58. package/.pi/extensions/lib/posthog-client.ts +76 -0
  59. package/.pi/extensions/policy-gate.ts +24 -19
  60. package/.pi/extensions/trace-recorder.ts +1 -0
  61. package/.pi/harness/agents.manifest.json +24 -16
  62. package/.pi/harness/corpus/cron.example +8 -0
  63. package/.pi/harness/corpus/graphify-kb-updater.config.json +159 -0
  64. package/.pi/harness/corpus/systemd/graphify-kb-updater.env.template +4 -0
  65. package/.pi/harness/corpus/systemd/graphify-kb-updater.service +17 -0
  66. package/.pi/harness/corpus/systemd/graphify-kb-updater.timer +11 -0
  67. package/.pi/harness/docs/adrs/0001-harness-constitution.md +2 -1
  68. package/.pi/harness/docs/adrs/0006-sentrux-dual-layer.md +7 -6
  69. package/.pi/harness/docs/adrs/0009-sentrux-rules-lifecycle.md +6 -1
  70. package/.pi/harness/docs/adrs/0031-harness-run-context.md +1 -1
  71. package/.pi/harness/docs/adrs/0032-harness-command-orchestration.md +7 -0
  72. package/.pi/harness/docs/adrs/0034-darwin-plan-research-pipeline.md +3 -3
  73. package/.pi/harness/docs/adrs/0036-implementation-research-and-selective-debate.md +8 -5
  74. package/.pi/harness/docs/adrs/0039-harness-post-run-review-gate.md +47 -0
  75. package/.pi/harness/docs/adrs/0040-practice-grounded-orchestration.md +40 -0
  76. package/.pi/harness/docs/adrs/0041-intelligent-planning-reconnaissance.md +39 -0
  77. package/.pi/harness/docs/adrs/0042-agent-native-orchestration.md +35 -0
  78. package/.pi/harness/docs/adrs/0043-path-first-harness-tools.md +38 -0
  79. package/.pi/harness/docs/adrs/0044-harness-steer-loop.md +36 -0
  80. package/.pi/harness/docs/adrs/README.md +10 -0
  81. package/.pi/harness/docs/graphify-kb-updater-runbook.md +157 -0
  82. package/.pi/harness/docs/practice-map.md +110 -0
  83. package/.pi/harness/env.harness.template +5 -3
  84. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/implementation-research.yaml +28 -0
  85. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/review-round-consolidated.yaml +25 -0
  86. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-packet.yaml +196 -0
  87. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-review.md +14 -0
  88. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/research-brief.yaml +62 -0
  89. package/.pi/harness/evals/smoke/sentrux-stub.json +1 -1
  90. package/.pi/harness/evals/smoke/smoke-harness-plan.mjs +43 -17
  91. package/.pi/harness/specs/README.md +1 -1
  92. package/.pi/harness/specs/harness-run-context.schema.json +11 -0
  93. package/.pi/harness/specs/harness-spawn-context.schema.json +14 -0
  94. package/.pi/harness/specs/plan-execution-plan.schema.json +39 -1
  95. package/.pi/harness/specs/plan-packet.schema.json +4 -0
  96. package/.pi/harness/specs/plan-phase-status.schema.json +17 -0
  97. package/.pi/harness/specs/plan-phase-waiver.schema.json +25 -0
  98. package/.pi/harness/specs/plan-planning-context.schema.json +50 -0
  99. package/.pi/harness/specs/plan-review-round-draft.schema.json +1 -1
  100. package/.pi/harness/specs/repair-brief.schema.json +45 -0
  101. package/.pi/harness/specs/review-outcome.schema.json +46 -0
  102. package/.pi/harness/specs/sentrux-manifest-proposal.schema.json +80 -0
  103. package/.pi/harness/specs/sentrux-signal.schema.json +43 -0
  104. package/.pi/harness/specs/steer-state.schema.json +20 -0
  105. package/.pi/lib/harness-context-mode-policy.ts +256 -0
  106. package/.pi/lib/harness-repair-brief.ts +145 -0
  107. package/.pi/lib/harness-run-context.ts +591 -32
  108. package/.pi/lib/harness-ui-state.ts +87 -9
  109. package/.pi/model-router.example.json +13 -4
  110. package/.pi/prompts/harness-auto.md +9 -9
  111. package/.pi/prompts/harness-critic.md +3 -30
  112. package/.pi/prompts/harness-eval.md +4 -37
  113. package/.pi/prompts/harness-plan.md +139 -57
  114. package/.pi/prompts/harness-review.md +150 -15
  115. package/.pi/prompts/harness-run.md +62 -10
  116. package/.pi/prompts/harness-sentrux-steward.md +55 -0
  117. package/.pi/prompts/harness-setup.md +4 -4
  118. package/.pi/prompts/harness-steer.md +30 -0
  119. package/.pi/scripts/graphify-kb-updater.mjs +358 -0
  120. package/.pi/scripts/harness-generate-model-router.mjs +118 -36
  121. package/.pi/scripts/harness-model-router-routing.test.mjs +97 -0
  122. package/.pi/scripts/harness-sync-model-router.mjs +15 -2
  123. package/.pi/scripts/harness-verify.mjs +51 -6
  124. package/.pi/scripts/harness-web-policy-guard.mjs +68 -0
  125. package/.pi/scripts/validate-plan-dag.mjs +3 -3
  126. package/AGENTS.md +1 -0
  127. package/CHANGELOG.md +22 -0
  128. package/package.json +5 -4
  129. package/vendor/pi-model-router/UPSTREAM_PIN.md +3 -1
  130. package/vendor/pi-model-router/extensions/commands.ts +4 -4
  131. package/vendor/pi-model-router/extensions/index.ts +21 -0
  132. package/vendor/pi-model-router/extensions/provider.ts +130 -79
  133. package/vendor/pi-model-router/extensions/routing.ts +148 -0
  134. package/vendor/pi-model-router/extensions/state.ts +3 -0
  135. package/vendor/pi-model-router/extensions/types.ts +9 -0
  136. package/vendor/pi-model-router/extensions/ui.ts +16 -2
  137. 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,9 +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: "Full artifact document matching the harness JSON schema",
22
- }),
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
+ ),
23
36
  },
24
37
  { additionalProperties: false },
25
38
  );
@@ -58,30 +71,6 @@ export default function harnessSubagentSubmit(pi: ExtensionAPI) {
58
71
  pi.on("tool_call", async (event) => {
59
72
  if (!event.toolName.startsWith("submit_")) return undefined;
60
73
  const subprocessOk = isSubprocessHarness();
61
- // #region agent log
62
- fetch("http://127.0.0.1:7928/ingest/a5d40896-34cb-4f12-97db-df7ada0b22f0", {
63
- method: "POST",
64
- headers: {
65
- "Content-Type": "application/json",
66
- "X-Debug-Session-Id": "2ca12b",
67
- },
68
- body: JSON.stringify({
69
- sessionId: "2ca12b",
70
- hypothesisId: "H2",
71
- location: "harness-subagent-submit.ts:tool_call",
72
- message: "submit tool_call gate",
73
- data: {
74
- toolName: event.toolName,
75
- PI_HARNESS_SUBPROCESS: process.env.PI_HARNESS_SUBPROCESS,
76
- HARNESS_RUN_ID: process.env.HARNESS_RUN_ID ?? null,
77
- HARNESS_RUN_DIR: process.env.HARNESS_RUN_DIR ?? null,
78
- HARNESS_AGENT_ID: process.env.HARNESS_AGENT_ID ?? null,
79
- subprocessOk,
80
- },
81
- timestamp: Date.now(),
82
- }),
83
- }).catch(() => {});
84
- // #endregion
85
74
  if (!subprocessOk) {
86
75
  return {
87
76
  block: true,
@@ -141,21 +130,42 @@ export default function harnessSubagentSubmit(pi: ExtensionAPI) {
141
130
  isError: true,
142
131
  };
143
132
  }
144
- const document = (params as { document?: Record<string, unknown> })
145
- .document;
146
- if (!document || typeof document !== "object") {
133
+ const runResolved = await resolveGuardedRunDir({
134
+ projectRoot,
135
+ runId,
136
+ runDirEnv,
137
+ });
138
+ if (!runResolved.ok) {
147
139
  return {
148
- content: [{ type: "text", text: "document object is required" }],
140
+ content: [{ type: "text", text: runResolved.error }],
149
141
  details: {},
150
142
  isError: true,
151
143
  };
152
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
+ }
153
163
  const result = await executeSubmitPipeline({
154
164
  projectRoot,
155
165
  specsDir,
156
166
  spec,
157
167
  agentId,
158
- document,
168
+ document: loaded.document,
159
169
  runId,
160
170
  runDirEnv,
161
171
  });
@@ -127,6 +127,7 @@ function propsFromRun(
127
127
  ): Record<string, unknown> {
128
128
  return {
129
129
  harness_run_id: runId,
130
+ run_id: runId,
130
131
  harness_plan_id: planId,
131
132
  harness_phase: phase,
132
133
  pi_session_id: distinctId,
@@ -134,6 +135,28 @@ function propsFromRun(
134
135
  };
135
136
  }
136
137
 
138
+ function normalizedRunId(
139
+ data: Record<string, unknown>,
140
+ trace: TraceState | null,
141
+ distinctId: string,
142
+ ): string {
143
+ const fromData = [
144
+ data.harness_run_id,
145
+ data.run_id,
146
+ data.runId,
147
+ data.debate_id,
148
+ ];
149
+ for (const candidate of fromData) {
150
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
151
+ return candidate;
152
+ }
153
+ }
154
+ if (typeof trace?.run_id === "string" && trace.run_id.length > 0) {
155
+ return trace.run_id;
156
+ }
157
+ return distinctId;
158
+ }
159
+
137
160
  function mapCustomEntry(
138
161
  customType: string,
139
162
  data: Record<string, unknown>,
@@ -144,11 +167,9 @@ function mapCustomEntry(
144
167
  event: HarnessPostHogEventName;
145
168
  properties: Record<string, unknown>;
146
169
  } | null {
147
- const runId =
148
- (typeof data.run_id === "string" && data.run_id) ||
149
- trace?.run_id ||
150
- distinctId;
170
+ const runId = normalizedRunId(data, trace, distinctId);
151
171
  const planId =
172
+ (typeof data.harness_plan_id === "string" && data.harness_plan_id) ||
152
173
  (typeof data.plan_id === "string" && data.plan_id) ||
153
174
  policy?.planId ||
154
175
  trace?.plan_id ||
@@ -185,6 +206,7 @@ function mapCustomEntry(
185
206
  event: "harness_debate_consensus",
186
207
  properties: {
187
208
  ...base,
209
+ debate_id: String(data.debate_id ?? runId),
188
210
  consensus_id:
189
211
  typeof data.debate_id === "string" ? data.debate_id : runId,
190
212
  outcome: String(kind),
@@ -195,6 +217,8 @@ function mapCustomEntry(
195
217
  event: "harness_debate_round",
196
218
  properties: {
197
219
  ...base,
220
+ debate_id: String(data.debate_id ?? runId),
221
+ round_index: Number(data.round_index ?? data.round ?? 0),
198
222
  round: Number(data.round_index ?? data.round ?? 0),
199
223
  outcome: String(kind ?? "round"),
200
224
  },
@@ -206,6 +230,7 @@ function mapCustomEntry(
206
230
  event: "harness_debate_consensus",
207
231
  properties: {
208
232
  ...base,
233
+ debate_id: String(data.debate_id ?? runId),
209
234
  consensus_id:
210
235
  typeof data.consensus_id === "string"
211
236
  ? data.consensus_id
@@ -25,6 +25,7 @@ import {
25
25
  } from "./debate-bus-state.js";
26
26
  import {
27
27
  type DebateProfile,
28
+ PLAN_BUDGET_FAST,
28
29
  PLAN_BUDGET_LIGHT,
29
30
  PLAN_BUDGET_STANDARD,
30
31
  } from "./plan-debate-eligibility.js";
@@ -113,15 +114,20 @@ export function capsForDebate(
113
114
  } {
114
115
  if (isPlanDebateId(debateId)) {
115
116
  const active = profile ?? getDebateState()?.debate_profile ?? "standard";
116
- const budget = active === "light" ? PLAN_BUDGET_LIGHT : PLAN_BUDGET;
117
+ const budget =
118
+ active === "light"
119
+ ? PLAN_BUDGET_LIGHT
120
+ : active === "fast"
121
+ ? PLAN_BUDGET_FAST
122
+ : PLAN_BUDGET;
117
123
  const caps = { name: "plan" as const, ...budget };
118
124
  if (!isHarnessBudgetEnforceOn()) {
119
125
  return {
120
126
  ...caps,
121
- max_rounds: 999,
122
- max_exchanges_per_round: 99,
123
- round_token_cap: caps.round_token_cap * 100,
124
- debate_global_cap: caps.debate_global_cap * 100,
127
+ max_rounds: caps.max_rounds,
128
+ max_exchanges_per_round: Math.max(caps.max_exchanges_per_round, 2),
129
+ round_token_cap: caps.round_token_cap * 2,
130
+ debate_global_cap: caps.debate_global_cap * 2,
125
131
  };
126
132
  }
127
133
  return caps;
@@ -135,10 +141,10 @@ export function capsForDebate(
135
141
  if (!isHarnessBudgetEnforceOn()) {
136
142
  return {
137
143
  ...caps,
138
- max_rounds: 999,
139
- max_exchanges_per_round: 99,
140
- round_token_cap: caps.round_token_cap * 100,
141
- debate_global_cap: caps.debate_global_cap * 100,
144
+ max_rounds: caps.max_rounds,
145
+ max_exchanges_per_round: Math.max(caps.max_exchanges_per_round, 2),
146
+ round_token_cap: caps.round_token_cap * 2,
147
+ debate_global_cap: caps.debate_global_cap * 2,
142
148
  };
143
149
  }
144
150
  return caps;
@@ -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
+ }