ultimate-pi 0.22.0 → 0.22.2

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 (78) hide show
  1. package/.agents/skills/harness-context/SKILL.md +3 -3
  2. package/.agents/skills/harness-debate-plan/SKILL.md +2 -2
  3. package/.agents/skills/harness-decisions/SKILL.md +2 -2
  4. package/.agents/skills/harness-eval/SKILL.md +1 -1
  5. package/.agents/skills/harness-git-commit/SKILL.md +1 -1
  6. package/.agents/skills/harness-governor/SKILL.md +5 -5
  7. package/.agents/skills/harness-ls-lint-setup/SKILL.md +2 -2
  8. package/.agents/skills/harness-orchestration/SKILL.md +4 -4
  9. package/.agents/skills/harness-plan/SKILL.md +2 -2
  10. package/.agents/skills/harness-review/SKILL.md +2 -2
  11. package/.agents/skills/harness-sentrux-repair/SKILL.md +1 -1
  12. package/.agents/skills/harness-sentrux-setup/SKILL.md +2 -2
  13. package/.agents/skills/harness-spec/SKILL.md +1 -1
  14. package/.agents/skills/harness-steer/SKILL.md +2 -2
  15. package/.agents/skills/posthog-analyst/SKILL.md +1 -1
  16. package/.agents/skills/sentrux/SKILL.md +4 -4
  17. package/.agents/skills/web-retrieval/SKILL.md +1 -1
  18. package/.pi/agents/harness/ls-lint-steward.md +3 -3
  19. package/.pi/agents/harness/planning/decompose.md +1 -1
  20. package/.pi/agents/harness/planning/execution-plan-author.md +1 -1
  21. package/.pi/agents/harness/planning/hypothesis-validator.md +1 -1
  22. package/.pi/agents/harness/planning/hypothesis.md +1 -1
  23. package/.pi/agents/harness/planning/plan-adversary.md +1 -1
  24. package/.pi/agents/harness/planning/plan-evaluator.md +2 -2
  25. package/.pi/agents/harness/planning/plan-synthesizer.md +2 -2
  26. package/.pi/agents/harness/planning/review-integrator.md +1 -1
  27. package/.pi/agents/harness/planning/sprint-contract-auditor.md +5 -5
  28. package/.pi/agents/harness/running/executor.md +1 -1
  29. package/.pi/agents/harness/sentrux-repair-advisor.md +1 -1
  30. package/.pi/agents/harness/sentrux-steward.md +2 -2
  31. package/.pi/extensions/agt-kill-switch.ts +7 -1
  32. package/.pi/extensions/harness-plan-approval.ts +9 -1
  33. package/.pi/extensions/harness-run-context.ts +529 -84
  34. package/.pi/extensions/policy-gate.ts +15 -2
  35. package/.pi/harness/agents.manifest.json +16 -16
  36. package/.pi/harness/agents.policy.yaml +82 -3
  37. package/.pi/harness/specs/plan-task-clarification.schema.json +10 -1
  38. package/.pi/lib/agents-policy.mjs +42 -1
  39. package/.pi/lib/agt/build-evaluation-context.ts +3 -1
  40. package/.pi/lib/agt/kill-switch-state.ts +14 -0
  41. package/.pi/lib/agt/legacy-evaluate.ts +3 -1
  42. package/.pi/lib/ask-user/index.ts +2 -0
  43. package/.pi/lib/ask-user/merge-task-clarification.ts +5 -0
  44. package/.pi/lib/ask-user/policy.ts +23 -0
  45. package/.pi/lib/ask-user/presenters/glimpse.ts +8 -1
  46. package/.pi/lib/ask-user/presenters/headless.ts +15 -0
  47. package/.pi/lib/ask-user/presenters/select.ts +11 -2
  48. package/.pi/lib/ask-user/validate-core.mjs +16 -0
  49. package/.pi/lib/harness-artifact-gate.ts +75 -5
  50. package/.pi/lib/harness-repair-brief.ts +30 -4
  51. package/.pi/lib/harness-run-context.ts +804 -17
  52. package/.pi/lib/harness-schema-validate.ts +147 -38
  53. package/.pi/lib/harness-spawn-policy.ts +9 -0
  54. package/.pi/lib/harness-spawn-topology.ts +109 -7
  55. package/.pi/lib/harness-subagent-precheck.ts +21 -0
  56. package/.pi/lib/harness-subagent-submit-pipeline.ts +95 -21
  57. package/.pi/lib/harness-subagent-submit-register.ts +6 -1
  58. package/.pi/lib/harness-subagents-bridge.ts +3 -0
  59. package/.pi/lib/harness-yaml.ts +11 -3
  60. package/.pi/lib/plan-approval/create-plan.ts +2 -6
  61. package/.pi/lib/plan-debate-gate.ts +87 -0
  62. package/.pi/lib/plan-debate-lane.ts +8 -2
  63. package/.pi/lib/plan-human-gates.ts +322 -0
  64. package/.pi/prompts/harness-clear.md +25 -0
  65. package/.pi/prompts/harness-plan.md +11 -7
  66. package/.pi/prompts/harness-review.md +5 -5
  67. package/.pi/prompts/harness-run.md +2 -2
  68. package/.pi/prompts/harness-sentrux-steward.md +2 -2
  69. package/.pi/prompts/harness-setup.md +3 -3
  70. package/.pi/prompts/harness-steer.md +5 -5
  71. package/.pi/scripts/generate-agents-policy-yaml.mjs +73 -7
  72. package/.pi/scripts/harness-reconcile-run-context.mjs +62 -0
  73. package/.pi/scripts/harness-schema-compile-verify.mjs +29 -0
  74. package/.pi/scripts/harness-verify.mjs +100 -0
  75. package/AGENTS.md +1 -0
  76. package/CHANGELOG.md +13 -0
  77. package/README.md +4 -0
  78. package/package.json +9 -6
@@ -2,7 +2,7 @@
2
2
  * JSON Schema validation for harness submit tools (Ajv draft 2020-12, offline).
3
3
  */
4
4
 
5
- import { appendFile, readFile } from "node:fs/promises";
5
+ import { readdir, readFile } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import Ajv2020 from "ajv/dist/2020";
8
8
  import addFormats from "ajv-formats";
@@ -10,8 +10,9 @@ import addFormats from "ajv-formats";
10
10
  type ValidateFn = (data: unknown) => boolean;
11
11
 
12
12
  const compileCache = new Map<string, ValidateFn>();
13
- const DEBUG_LOG_PATH =
14
- "/home/aryaniyaps/ai-projects/ultimate-pi/.cursor/debug-2ca12b.log";
13
+ const registeredSchemaIds = new Set<string>();
14
+
15
+ export const EXTERNAL_SCHEMA_REF = /^[a-z0-9._-]+\.schema\.json$/i;
15
16
 
16
17
  let ajvSingleton: InstanceType<typeof Ajv2020> | null = null;
17
18
 
@@ -27,28 +28,143 @@ function getAjv(): InstanceType<typeof Ajv2020> {
27
28
  return ajvSingleton;
28
29
  }
29
30
 
30
- async function debugLog(
31
- hypothesisId: string,
32
- message: string,
33
- data: Record<string, unknown>,
31
+ /** Collect sibling `*.schema.json` $ref targets (not `#/$defs/...`). */
32
+ export function collectExternalSchemaRefs(
33
+ node: unknown,
34
+ out: Set<string>,
35
+ ): void {
36
+ if (!node || typeof node !== "object") return;
37
+ if (Array.isArray(node)) {
38
+ for (const item of node) collectExternalSchemaRefs(item, out);
39
+ return;
40
+ }
41
+ const obj = node as Record<string, unknown>;
42
+ const ref = obj.$ref;
43
+ if (typeof ref === "string" && EXTERNAL_SCHEMA_REF.test(ref)) {
44
+ out.add(ref);
45
+ }
46
+ for (const value of Object.values(obj)) {
47
+ collectExternalSchemaRefs(value, out);
48
+ }
49
+ }
50
+
51
+ async function loadHarnessSchema(
52
+ specsDir: string,
53
+ schemaFile: string,
54
+ ): Promise<Record<string, unknown>> {
55
+ const schemaPath = join(specsDir, schemaFile);
56
+ const raw = await readFile(schemaPath, "utf-8");
57
+ return JSON.parse(raw) as Record<string, unknown>;
58
+ }
59
+
60
+ /** Register cross-file $ref targets only; root is registered by `compile()`. */
61
+ async function ensureHarnessSchemaDependencies(
62
+ ajv: InstanceType<typeof Ajv2020>,
63
+ specsDir: string,
64
+ schemaFile: string,
65
+ loading: Set<string>,
34
66
  ): Promise<void> {
35
- // #region agent log
67
+ if (loading.has(schemaFile)) return;
68
+ loading.add(schemaFile);
69
+
70
+ const schema = await loadHarnessSchema(specsDir, schemaFile);
71
+ const schemaId = String(schema.$id ?? schemaFile);
72
+
73
+ const externalRefs = new Set<string>();
74
+ collectExternalSchemaRefs(schema, externalRefs);
75
+ for (const refFile of externalRefs) {
76
+ await ensureHarnessSchemaDependencies(ajv, specsDir, refFile, loading);
77
+ }
78
+
79
+ if (!registeredSchemaIds.has(schemaId)) {
80
+ ajv.addSchema(schema, schemaId);
81
+ registeredSchemaIds.add(schemaId);
82
+ }
83
+
84
+ loading.delete(schemaFile);
85
+ }
86
+
87
+ /** Compile a harness schema (registers cross-file $ref targets first). */
88
+ export async function compileHarnessSchema(
89
+ specsDir: string,
90
+ schemaFile: string,
91
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
92
+ const cacheKey = `${specsDir}:${schemaFile}`;
93
+ if (compileCache.has(cacheKey)) {
94
+ return { ok: true };
95
+ }
36
96
  try {
37
- await appendFile(
38
- DEBUG_LOG_PATH,
39
- `${JSON.stringify({
40
- sessionId: "2ca12b",
41
- hypothesisId,
42
- location: "harness-schema-validate.ts",
43
- message,
44
- data,
45
- timestamp: Date.now(),
46
- })}\n`,
47
- );
48
- } catch {
49
- /* ignore */
97
+ const schema = await loadHarnessSchema(specsDir, schemaFile);
98
+ const ajv = getAjv();
99
+ const externalRefs = new Set<string>();
100
+ collectExternalSchemaRefs(schema, externalRefs);
101
+ for (const refFile of externalRefs) {
102
+ await ensureHarnessSchemaDependencies(ajv, specsDir, refFile, new Set());
103
+ }
104
+ const schemaId = String(schema.$id ?? schemaFile);
105
+ let compiled: ValidateFn;
106
+ if (registeredSchemaIds.has(schemaId)) {
107
+ const existing = ajv.getSchema(schemaId);
108
+ if (!existing) {
109
+ return {
110
+ ok: false,
111
+ error: `schema ${schemaId} registered but not retrievable from Ajv`,
112
+ };
113
+ }
114
+ compiled = existing;
115
+ } else {
116
+ compiled = ajv.compile(schema);
117
+ registeredSchemaIds.add(schemaId);
118
+ }
119
+ compileCache.set(cacheKey, compiled);
120
+ return { ok: true };
121
+ } catch (err) {
122
+ const msg = err instanceof Error ? err.message : String(err);
123
+ return { ok: false, error: msg };
50
124
  }
51
- // #endregion
125
+ }
126
+
127
+ export async function listHarnessSpecSchemaFiles(
128
+ specsDir: string,
129
+ ): Promise<string[]> {
130
+ const names = await readdir(specsDir);
131
+ return names.filter((n) => n.endsWith(".schema.json")).sort();
132
+ }
133
+
134
+ /** Ensure every listed schema compiles; fails on missing cross-file $ref targets. */
135
+ export async function verifyHarnessSchemasCompile(
136
+ specsDir: string,
137
+ schemaFiles: string[],
138
+ ): Promise<{ ok: true } | { ok: false; errors: string[] }> {
139
+ const errors: string[] = [];
140
+ for (const schemaFile of schemaFiles) {
141
+ const compiled = await compileHarnessSchema(specsDir, schemaFile);
142
+ if (!compiled.ok) {
143
+ errors.push(`${schemaFile}: schema compile failed: ${compiled.error}`);
144
+ }
145
+ }
146
+ return errors.length > 0 ? { ok: false, errors } : { ok: true };
147
+ }
148
+
149
+ /** Every `*.schema.json` $ref in specs must point at a file on disk. */
150
+ export async function verifyHarnessSchemaRefIntegrity(
151
+ specsDir: string,
152
+ ): Promise<{ ok: true } | { ok: false; errors: string[] }> {
153
+ const errors: string[] = [];
154
+ const files = await listHarnessSpecSchemaFiles(specsDir);
155
+ for (const schemaFile of files) {
156
+ const schema = await loadHarnessSchema(specsDir, schemaFile);
157
+ const externalRefs = new Set<string>();
158
+ collectExternalSchemaRefs(schema, externalRefs);
159
+ for (const ref of externalRefs) {
160
+ try {
161
+ await loadHarnessSchema(specsDir, ref);
162
+ } catch {
163
+ errors.push(`${schemaFile}: missing $ref target ${ref}`);
164
+ }
165
+ }
166
+ }
167
+ return errors.length > 0 ? { ok: false, errors } : { ok: true };
52
168
  }
53
169
 
54
170
  export async function validateAgainstHarnessSchema(
@@ -56,23 +172,16 @@ export async function validateAgainstHarnessSchema(
56
172
  schemaFile: string,
57
173
  document: unknown,
58
174
  ): Promise<{ ok: true } | { ok: false; errors: string[] }> {
59
- const cacheKey = `${specsDir}:${schemaFile}`;
60
- let validate = compileCache.get(cacheKey);
175
+ const compiled = await compileHarnessSchema(specsDir, schemaFile);
176
+ if (!compiled.ok) {
177
+ return { ok: false, errors: [`schema compile failed: ${compiled.error}`] };
178
+ }
179
+ const validate = compileCache.get(`${specsDir}:${schemaFile}`);
61
180
  if (!validate) {
62
- const schemaPath = join(specsDir, schemaFile);
63
- const raw = await readFile(schemaPath, "utf-8");
64
- const schema = JSON.parse(raw) as Record<string, unknown>;
65
- try {
66
- const ajv = getAjv();
67
- const compiled = ajv.compile(schema);
68
- validate = compiled;
69
- compileCache.set(cacheKey, compiled);
70
- await debugLog("H3", "schema compile ok", { schemaFile });
71
- } catch (err) {
72
- const msg = err instanceof Error ? err.message : String(err);
73
- await debugLog("H3", "schema compile failed", { schemaFile, error: msg });
74
- return { ok: false, errors: [`schema compile failed: ${msg}`] };
75
- }
181
+ return {
182
+ ok: false,
183
+ errors: [`schema compile failed: ${schemaFile} not in compile cache`],
184
+ };
76
185
  }
77
186
  const ok = validate(document);
78
187
  if (ok) return { ok: true };
@@ -23,10 +23,19 @@ export interface ToolCallDecision {
23
23
  newArgs?: Record<string, unknown>;
24
24
  }
25
25
 
26
+ export interface EvaluateSubagentToolCallOptions {
27
+ /** Parent harness session — may spawn subagents; spawn policy applies only in subprocesses. */
28
+ isParentOrchestrator?: boolean;
29
+ }
30
+
26
31
  export function evaluateSubagentToolCall(
27
32
  toolName: string,
28
33
  agentType?: string,
34
+ opts?: EvaluateSubagentToolCallOptions,
29
35
  ): ToolCallDecision {
36
+ if (opts?.isParentOrchestrator) {
37
+ return { action: "allow" };
38
+ }
30
39
  if (SUBAGENT_BLOCKED_TOOLS.has(toolName)) {
31
40
  return {
32
41
  action: "block",
@@ -3,10 +3,12 @@
3
3
  */
4
4
 
5
5
  import { constants } from "node:fs";
6
- import { access } from "node:fs/promises";
6
+ import { access, readFile } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
+ import { parse as parseYaml } from "yaml";
9
+ import { validateHarnessArtifactFile } from "./harness-artifact-gate.js";
8
10
  import type { HarnessPhase } from "./harness-run-context.js";
9
- import { isTaskClarificationReady } from "./plan-task-clarification.js";
11
+ import { validateTaskClarificationReadyWithHumanGate } from "./plan-human-gates.js";
10
12
 
11
13
  export interface SpawnTopologyResult {
12
14
  ok: boolean;
@@ -31,6 +33,21 @@ const PARALLEL_RESEARCH_AGENTS = new Set([
31
33
  "harness/planning/stack-researcher",
32
34
  ]);
33
35
 
36
+ /** Single-shot plan agents: one deliverable artifact per run (debate lanes excluded). */
37
+ const PLANNING_AGENT_ARTIFACT: Record<string, string> = {
38
+ [PLANNING_CONTEXT_AGENT]: "artifacts/planning-context.yaml",
39
+ [DECOMPOSE_AGENT]: "artifacts/decomposition.yaml",
40
+ [HYPOTHESIS_AGENT]: "artifacts/hypothesis.yaml",
41
+ "harness/planning/implementation-researcher":
42
+ "artifacts/implementation-research.yaml",
43
+ "harness/planning/stack-researcher": "artifacts/stack.yaml",
44
+ "harness/planning/execution-plan-author":
45
+ "artifacts/execution-plan-draft.yaml",
46
+ "harness/planning/plan-synthesizer": "artifacts/execution-plan-draft.yaml",
47
+ "harness/sentrux-steward": "artifacts/sentrux-manifest-proposal.yaml",
48
+ "harness/ls-lint-steward": "artifacts/ls-lint-manifest-proposal.yaml",
49
+ };
50
+
34
51
  const CLARIFICATION_GATED_AGENTS = new Set([
35
52
  PLANNING_CONTEXT_AGENT,
36
53
  DECOMPOSE_AGENT,
@@ -132,17 +149,32 @@ function validateParallelBatch(
132
149
  async function validateClarificationGate(
133
150
  names: string[],
134
151
  phase: HarnessPhase,
135
- opts?: { projectRoot?: string; runId?: string | null },
152
+ opts?: {
153
+ projectRoot?: string;
154
+ runId?: string | null;
155
+ entries?: unknown[];
156
+ quick?: boolean;
157
+ taskSummary?: string;
158
+ lastOutcome?: string | null;
159
+ },
136
160
  ): Promise<string | null> {
137
161
  if (!(phase === "plan" && opts?.projectRoot && opts?.runId)) return null;
138
162
  const needsClar = names.some((n) => CLARIFICATION_GATED_AGENTS.has(n));
139
163
  if (!needsClar) return null;
140
164
  const runDir = join(opts.projectRoot, ".pi", "harness", "runs", opts.runId);
141
- const clar = await isTaskClarificationReady(runDir);
165
+ const clar = await validateTaskClarificationReadyWithHumanGate(
166
+ runDir,
167
+ opts.entries ?? [],
168
+ {
169
+ quick: opts.quick,
170
+ taskSummary: opts.taskSummary,
171
+ lastOutcome: opts.lastOutcome,
172
+ },
173
+ );
142
174
  if (clar.ok) return null;
143
175
  return (
144
176
  "Cannot spawn planning subagents before task clarification is ready. " +
145
- `Complete Phase 0 and harness_artifact_ready on artifacts/task-clarification.yaml. ${clar.errors.join("; ")}`
177
+ `Complete Phase 0 (ask_user + harness_artifact_ready on artifacts/task-clarification.yaml). ${clar.errors.join("; ")}`
146
178
  );
147
179
  }
148
180
 
@@ -171,6 +203,68 @@ function validatePlanPhaseMutations(
171
203
  return `Plan phase: cannot spawn mutating subagents (${mutating.join(", ")}).`;
172
204
  }
173
205
 
206
+ async function artifactAllowsRespawn(
207
+ runRoot: string,
208
+ artifactRel: string,
209
+ ): Promise<boolean> {
210
+ const abs = join(runRoot, artifactRel);
211
+ try {
212
+ await access(abs, constants.R_OK);
213
+ } catch {
214
+ return true;
215
+ }
216
+ try {
217
+ const raw = await readFile(abs, "utf-8");
218
+ const doc = parseYaml(raw) as Record<string, unknown>;
219
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
220
+ return true;
221
+ }
222
+ const status = String(doc.status ?? "ok").toLowerCase();
223
+ return status === "partial" || status === "failed" || status === "error";
224
+ } catch {
225
+ return true;
226
+ }
227
+ }
228
+
229
+ async function validateArtifactCompletionDedup(
230
+ names: string[],
231
+ phase: HarnessPhase,
232
+ opts?: {
233
+ projectRoot?: string;
234
+ runId?: string | null;
235
+ forceRespawn?: boolean;
236
+ },
237
+ ): Promise<string | null> {
238
+ if (phase !== "plan" || !opts?.projectRoot || !opts?.runId) return null;
239
+ if (opts.forceRespawn || process.env.HARNESS_FORCE_RESPAWN === "1") {
240
+ return null;
241
+ }
242
+
243
+ const runRoot = join(opts.projectRoot, ".pi", "harness", "runs", opts.runId);
244
+ const specsDir = join(opts.projectRoot, ".pi", "harness", "specs");
245
+
246
+ for (const name of names) {
247
+ const artifactRel = PLANNING_AGENT_ARTIFACT[name];
248
+ if (!artifactRel) continue;
249
+ if (await artifactAllowsRespawn(runRoot, artifactRel)) continue;
250
+
251
+ const gate = await validateHarnessArtifactFile(
252
+ runRoot,
253
+ artifactRel,
254
+ specsDir,
255
+ { skipPrerequisites: true },
256
+ );
257
+ if (!gate.ok) continue;
258
+
259
+ return (
260
+ `Duplicate spawn blocked: ${name} already produced a valid ${artifactRel}. ` +
261
+ `Call harness_artifact_ready on that path and advance to the next plan phase instead of re-spawning. ` +
262
+ `To force a re-run, set HARNESS_FORCE_RESPAWN=1 or revise/delete the artifact.`
263
+ );
264
+ }
265
+ return null;
266
+ }
267
+
174
268
  export async function validateHarnessSpawnTopology(
175
269
  names: string[],
176
270
  phase: HarnessPhase,
@@ -178,6 +272,11 @@ export async function validateHarnessSpawnTopology(
178
272
  parallelTaskCount?: number;
179
273
  projectRoot?: string;
180
274
  runId?: string | null;
275
+ entries?: unknown[];
276
+ quick?: boolean;
277
+ taskSummary?: string;
278
+ lastOutcome?: string | null;
279
+ forceRespawn?: boolean;
181
280
  },
182
281
  ): Promise<SpawnTopologyResult> {
183
282
  const taskCount =
@@ -186,11 +285,14 @@ export async function validateHarnessSpawnTopology(
186
285
  const parallelError = validateParallelBatch(names, taskCount);
187
286
  if (parallelError) return { ok: false, message: parallelError };
188
287
 
288
+ const hypothesisError = await validateHypothesisDependency(names, opts);
289
+ if (hypothesisError) return { ok: false, message: hypothesisError };
290
+
189
291
  const clarError = await validateClarificationGate(names, phase, opts);
190
292
  if (clarError) return { ok: false, message: clarError };
191
293
 
192
- const hypothesisError = await validateHypothesisDependency(names, opts);
193
- if (hypothesisError) return { ok: false, message: hypothesisError };
294
+ const dedupError = await validateArtifactCompletionDedup(names, phase, opts);
295
+ if (dedupError) return { ok: false, message: dedupError };
194
296
 
195
297
  const mutationError = validatePlanPhaseMutations(names, phase);
196
298
  if (mutationError) return { ok: false, message: mutationError };
@@ -10,6 +10,7 @@ import { getAgentKind } from "./agents-policy.mjs";
10
10
  import { getHarnessPackageRoot } from "./harness-paths.js";
11
11
  import { type HarnessPhase, inferHarnessPhase } from "./harness-run-context.js";
12
12
  import { validateHarnessSpawnTopology } from "./harness-spawn-topology.js";
13
+ import { shouldBlockSubagentForMissingPlanApproval } from "./plan-human-gates.js";
13
14
 
14
15
  export interface SubagentTaskRef {
15
16
  agent: string;
@@ -23,6 +24,10 @@ export interface PrecheckResult {
23
24
  export interface PrecheckOptions {
24
25
  projectRoot?: string;
25
26
  runId?: string | null;
27
+ entries?: unknown[];
28
+ quick?: boolean;
29
+ taskSummary?: string;
30
+ lastOutcome?: string | null;
26
31
  }
27
32
 
28
33
  function collectAgents(params: {
@@ -98,11 +103,27 @@ export async function precheckHarnessSubagentSpawn(
98
103
  parallelTaskCount,
99
104
  projectRoot: opts?.projectRoot,
100
105
  runId: opts?.runId,
106
+ entries: opts?.entries,
107
+ quick: opts?.quick,
108
+ taskSummary: opts?.taskSummary,
109
+ lastOutcome: opts?.lastOutcome,
101
110
  });
102
111
  if (!topology.ok) {
103
112
  return topology;
104
113
  }
105
114
 
115
+ if (phase === "plan" && opts?.projectRoot && opts?.runId && opts?.entries) {
116
+ const approvalBlock = await shouldBlockSubagentForMissingPlanApproval(
117
+ opts.projectRoot,
118
+ opts.runId,
119
+ opts.entries,
120
+ phase,
121
+ );
122
+ if (approvalBlock.block) {
123
+ return { ok: false, message: approvalBlock.reason };
124
+ }
125
+ }
126
+
106
127
  const packageRoot = getHarnessPackageRoot(
107
128
  // @ts-expect-error pi extensions run as ESM
108
129
  import.meta.url,
@@ -4,13 +4,14 @@
4
4
 
5
5
  import { mkdir, readFile } from "node:fs/promises";
6
6
  import { dirname, join, resolve } from "node:path";
7
+ import { parse as parseYaml } from "yaml";
7
8
  import { validateAgainstHarnessSchema } from "./harness-schema-validate.js";
8
9
  import { resolveGuardedRunDir } from "./harness-subagent-submit-path.js";
9
10
  import {
10
11
  resolveArtifactRelPath,
11
12
  type SubmitToolSpec,
12
13
  } from "./harness-subagent-submit-registry.js";
13
- import { writeYamlFile } from "./harness-yaml.js";
14
+ import { stringifyYaml, writeYamlFile } from "./harness-yaml.js";
14
15
  import {
15
16
  type ApplyDebateLaneResult,
16
17
  applyDebateLaneFromDoc,
@@ -22,6 +23,34 @@ export interface SubmitPipelineResult {
22
23
  validation_errors?: string[];
23
24
  lane_result?: ApplyDebateLaneResult;
24
25
  human_required?: boolean;
26
+ /** Artifact already passed gate with equivalent content — do not resubmit. */
27
+ idempotent?: boolean;
28
+ }
29
+
30
+ async function artifactBytesMatch(
31
+ absPath: string,
32
+ document: Record<string, unknown>,
33
+ ): Promise<boolean> {
34
+ try {
35
+ const existingDoc = await readYamlObject(absPath);
36
+ if (!existingDoc) return false;
37
+ return stringifyYaml(existingDoc).trim() === stringifyYaml(document).trim();
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ async function readYamlObject(
44
+ absPath: string,
45
+ ): Promise<Record<string, unknown> | null> {
46
+ try {
47
+ const raw = await readFile(absPath, "utf-8");
48
+ const doc = parseYaml(raw) as Record<string, unknown>;
49
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
50
+ return doc;
51
+ } catch {
52
+ return null;
53
+ }
25
54
  }
26
55
 
27
56
  export async function loadSubmitDocument(opts: {
@@ -54,8 +83,7 @@ export async function loadSubmitDocument(opts: {
54
83
  }
55
84
  try {
56
85
  const raw = await readFile(abs, "utf-8");
57
- const { parse } = await import("yaml");
58
- const doc = parse(raw) as Record<string, unknown>;
86
+ const doc = parseYaml(raw) as Record<string, unknown>;
59
87
  if (!doc || typeof doc !== "object") {
60
88
  return {
61
89
  ok: false,
@@ -101,28 +129,50 @@ export async function executeSubmitPipeline(opts: {
101
129
 
102
130
  const relPath = resolveArtifactRelPath(opts.spec, opts.document);
103
131
  const absPath = join(runResolved.runDir, relPath);
104
- await mkdir(dirname(absPath), { recursive: true });
105
- await writeYamlFile(absPath, opts.document);
106
132
 
107
- if (opts.spec.toolName === "submit_executor_handoff") {
108
- const rollback = opts.document.rollback_refs;
109
- if (rollback && typeof rollback === "object" && !Array.isArray(rollback)) {
110
- const rollbackPath = join(
111
- runResolved.runDir,
112
- "artifacts",
113
- "executor-rollback.yaml",
114
- );
115
- await mkdir(dirname(rollbackPath), { recursive: true });
116
- await writeYamlFile(rollbackPath, {
117
- schema_version: "1.0.0",
118
- ...(rollback as Record<string, unknown>),
119
- });
133
+ const existingDoc = await readYamlObject(absPath);
134
+ if (existingDoc) {
135
+ const existingValidation = await validateAgainstHarnessSchema(
136
+ opts.specsDir,
137
+ opts.spec.schemaFile,
138
+ existingDoc,
139
+ );
140
+ if (
141
+ existingValidation.ok &&
142
+ (await artifactBytesMatch(absPath, opts.document))
143
+ ) {
144
+ let laneResult: ApplyDebateLaneResult | undefined;
145
+ if (opts.spec.debateLane) {
146
+ laneResult = await applyDebateLaneFromDoc({
147
+ runDir: runResolved.runDir,
148
+ lane: opts.spec.debateLane,
149
+ doc: opts.document,
150
+ skipArtifactWrite: true,
151
+ });
152
+ if (!laneResult.ok) {
153
+ return {
154
+ ok: false,
155
+ artifact_path: relPath,
156
+ validation_errors: [
157
+ ...laneResult.errors,
158
+ "Artifact already on disk with the same content; fix messenger/lane fields above and resubmit once (do not loop identical submits).",
159
+ ],
160
+ lane_result: laneResult,
161
+ };
162
+ }
163
+ }
164
+ return {
165
+ ok: true,
166
+ artifact_path: relPath,
167
+ lane_result: laneResult,
168
+ human_required: opts.spec.humanRequired === true,
169
+ idempotent: true,
170
+ };
120
171
  }
121
172
  }
122
173
 
123
- let laneResult: ApplyDebateLaneResult | undefined;
124
174
  if (opts.spec.debateLane) {
125
- laneResult = await applyDebateLaneFromDoc({
175
+ const laneResult = await applyDebateLaneFromDoc({
126
176
  runDir: runResolved.runDir,
127
177
  lane: opts.spec.debateLane,
128
178
  doc: opts.document,
@@ -135,12 +185,36 @@ export async function executeSubmitPipeline(opts: {
135
185
  lane_result: laneResult,
136
186
  };
137
187
  }
188
+ return {
189
+ ok: true,
190
+ artifact_path: relPath,
191
+ lane_result: laneResult,
192
+ human_required: opts.spec.humanRequired === true,
193
+ };
194
+ }
195
+
196
+ await mkdir(dirname(absPath), { recursive: true });
197
+ await writeYamlFile(absPath, opts.document);
198
+
199
+ if (opts.spec.toolName === "submit_executor_handoff") {
200
+ const rollback = opts.document.rollback_refs;
201
+ if (rollback && typeof rollback === "object" && !Array.isArray(rollback)) {
202
+ const rollbackPath = join(
203
+ runResolved.runDir,
204
+ "artifacts",
205
+ "executor-rollback.yaml",
206
+ );
207
+ await mkdir(dirname(rollbackPath), { recursive: true });
208
+ await writeYamlFile(rollbackPath, {
209
+ schema_version: "1.0.0",
210
+ ...(rollback as Record<string, unknown>),
211
+ });
212
+ }
138
213
  }
139
214
 
140
215
  return {
141
216
  ok: true,
142
217
  artifact_path: relPath,
143
- lane_result: laneResult,
144
218
  human_required: opts.spec.humanRequired === true,
145
219
  };
146
220
  }
@@ -146,7 +146,12 @@ export function registerHarnessSubagentSubmitTools(
146
146
  details: result,
147
147
  };
148
148
  }
149
- const lines = [`ok: wrote ${result.artifact_path}`];
149
+ const lines = result.idempotent
150
+ ? [
151
+ `ok: idempotent — ${result.artifact_path} already valid with the same content`,
152
+ "Do NOT call this submit tool again; end the turn.",
153
+ ]
154
+ : [`ok: wrote ${result.artifact_path}`];
150
155
  if (result.lane_result?.messenger_posted) {
151
156
  lines.push("messenger updated");
152
157
  }
@@ -216,6 +216,9 @@ export function createHarnessSubagentsExtension(
216
216
  {
217
217
  projectRoot: ctx.cwd,
218
218
  runId: runCtx?.run_id ?? null,
219
+ entries,
220
+ taskSummary: runCtx?.task_summary ?? undefined,
221
+ lastOutcome: runCtx?.last_outcome ?? undefined,
219
222
  },
220
223
  );
221
224
  if (!pre.ok) {
@@ -2,7 +2,8 @@
2
2
  * YAML read/write for harness plan artifacts (no JSON plan fallbacks).
3
3
  */
4
4
 
5
- import { readFile, rename, writeFile } from "node:fs/promises";
5
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
6
7
  import { parse, stringify } from "yaml";
7
8
 
8
9
  const CODE_FENCE_RE = /^```(?:ya?ml|json)?\s*\n?([\s\S]*?)```\s*$/im;
@@ -72,8 +73,15 @@ export async function writeYamlFile(
72
73
  ): Promise<void> {
73
74
  const tmp = `${path}.tmp`;
74
75
  const content = `${stringify(data, { indent: 2 })}\n`;
75
- await writeFile(tmp, content, "utf-8");
76
- await rename(tmp, path);
76
+ const parent = dirname(path);
77
+ await mkdir(parent, { recursive: true });
78
+ try {
79
+ await writeFile(tmp, content, "utf-8");
80
+ await rename(tmp, path);
81
+ } catch {
82
+ await mkdir(parent, { recursive: true });
83
+ await writeFile(path, content, "utf-8");
84
+ }
77
85
  }
78
86
 
79
87
  export function stringifyYaml(data: unknown): string {
@@ -106,12 +106,8 @@ export async function executeCreatePlan(
106
106
  updated_at: new Date().toISOString(),
107
107
  };
108
108
 
109
- try {
110
- await saveRunContextToDisk(updated);
111
- await saveProjectActiveRun(updated);
112
- } catch {
113
- /* disk mirror best-effort */
114
- }
109
+ await saveRunContextToDisk(updated);
110
+ await saveProjectActiveRun(updated);
115
111
 
116
112
  await writePlanReviewMarkdown(deps.projectRoot, updated, planPacket, {
117
113
  status: "committed",