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.
- package/.agents/skills/harness-context/SKILL.md +3 -3
- package/.agents/skills/harness-debate-plan/SKILL.md +2 -2
- package/.agents/skills/harness-decisions/SKILL.md +2 -2
- package/.agents/skills/harness-eval/SKILL.md +1 -1
- package/.agents/skills/harness-git-commit/SKILL.md +1 -1
- package/.agents/skills/harness-governor/SKILL.md +5 -5
- package/.agents/skills/harness-ls-lint-setup/SKILL.md +2 -2
- package/.agents/skills/harness-orchestration/SKILL.md +4 -4
- package/.agents/skills/harness-plan/SKILL.md +2 -2
- package/.agents/skills/harness-review/SKILL.md +2 -2
- package/.agents/skills/harness-sentrux-repair/SKILL.md +1 -1
- package/.agents/skills/harness-sentrux-setup/SKILL.md +2 -2
- package/.agents/skills/harness-spec/SKILL.md +1 -1
- package/.agents/skills/harness-steer/SKILL.md +2 -2
- package/.agents/skills/posthog-analyst/SKILL.md +1 -1
- package/.agents/skills/sentrux/SKILL.md +4 -4
- package/.agents/skills/web-retrieval/SKILL.md +1 -1
- package/.pi/agents/harness/ls-lint-steward.md +3 -3
- package/.pi/agents/harness/planning/decompose.md +1 -1
- package/.pi/agents/harness/planning/execution-plan-author.md +1 -1
- package/.pi/agents/harness/planning/hypothesis-validator.md +1 -1
- package/.pi/agents/harness/planning/hypothesis.md +1 -1
- package/.pi/agents/harness/planning/plan-adversary.md +1 -1
- package/.pi/agents/harness/planning/plan-evaluator.md +2 -2
- package/.pi/agents/harness/planning/plan-synthesizer.md +2 -2
- package/.pi/agents/harness/planning/review-integrator.md +1 -1
- package/.pi/agents/harness/planning/sprint-contract-auditor.md +5 -5
- package/.pi/agents/harness/running/executor.md +1 -1
- package/.pi/agents/harness/sentrux-repair-advisor.md +1 -1
- package/.pi/agents/harness/sentrux-steward.md +2 -2
- package/.pi/extensions/agt-kill-switch.ts +7 -1
- package/.pi/extensions/harness-plan-approval.ts +9 -1
- package/.pi/extensions/harness-run-context.ts +529 -84
- package/.pi/extensions/policy-gate.ts +15 -2
- package/.pi/harness/agents.manifest.json +16 -16
- package/.pi/harness/agents.policy.yaml +82 -3
- package/.pi/harness/specs/plan-task-clarification.schema.json +10 -1
- package/.pi/lib/agents-policy.mjs +42 -1
- package/.pi/lib/agt/build-evaluation-context.ts +3 -1
- package/.pi/lib/agt/kill-switch-state.ts +14 -0
- package/.pi/lib/agt/legacy-evaluate.ts +3 -1
- package/.pi/lib/ask-user/index.ts +2 -0
- package/.pi/lib/ask-user/merge-task-clarification.ts +5 -0
- package/.pi/lib/ask-user/policy.ts +23 -0
- package/.pi/lib/ask-user/presenters/glimpse.ts +8 -1
- package/.pi/lib/ask-user/presenters/headless.ts +15 -0
- package/.pi/lib/ask-user/presenters/select.ts +11 -2
- package/.pi/lib/ask-user/validate-core.mjs +16 -0
- package/.pi/lib/harness-artifact-gate.ts +75 -5
- package/.pi/lib/harness-repair-brief.ts +30 -4
- package/.pi/lib/harness-run-context.ts +804 -17
- package/.pi/lib/harness-schema-validate.ts +147 -38
- package/.pi/lib/harness-spawn-policy.ts +9 -0
- package/.pi/lib/harness-spawn-topology.ts +109 -7
- package/.pi/lib/harness-subagent-precheck.ts +21 -0
- package/.pi/lib/harness-subagent-submit-pipeline.ts +95 -21
- package/.pi/lib/harness-subagent-submit-register.ts +6 -1
- package/.pi/lib/harness-subagents-bridge.ts +3 -0
- package/.pi/lib/harness-yaml.ts +11 -3
- package/.pi/lib/plan-approval/create-plan.ts +2 -6
- package/.pi/lib/plan-debate-gate.ts +87 -0
- package/.pi/lib/plan-debate-lane.ts +8 -2
- package/.pi/lib/plan-human-gates.ts +322 -0
- package/.pi/prompts/harness-clear.md +25 -0
- package/.pi/prompts/harness-plan.md +11 -7
- package/.pi/prompts/harness-review.md +5 -5
- package/.pi/prompts/harness-run.md +2 -2
- package/.pi/prompts/harness-sentrux-steward.md +2 -2
- package/.pi/prompts/harness-setup.md +3 -3
- package/.pi/prompts/harness-steer.md +5 -5
- package/.pi/scripts/generate-agents-policy-yaml.mjs +73 -7
- package/.pi/scripts/harness-reconcile-run-context.mjs +62 -0
- package/.pi/scripts/harness-schema-compile-verify.mjs +29 -0
- package/.pi/scripts/harness-verify.mjs +100 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +13 -0
- package/README.md +4 -0
- 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 {
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 {
|
|
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?: {
|
|
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
|
|
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
|
|
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
|
|
193
|
-
if (
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
await
|
|
117
|
-
|
|
118
|
-
|
|
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 =
|
|
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) {
|
package/.pi/lib/harness-yaml.ts
CHANGED
|
@@ -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
|
-
|
|
76
|
-
await
|
|
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
|
-
|
|
110
|
-
|
|
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",
|