@tea-agent/loop-agent 0.26.3 → 0.26.5-beta.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.
- package/CHANGELOG.md +20 -0
- package/dist/executors/dag-pi-executor.js +26 -1
- package/dist/executors/pi-executor.js +233 -147
- package/dist/executors/pi-sdk-executor.js +140 -81
- package/dist/executors/pi-writer-tool-policy.js +266 -0
- package/dist/executors/shell-executor.js +355 -52
- package/dist/governance/document-index-closure.js +164 -0
- package/dist/worker/observe/node-input.js +8 -11
- package/dist/workflows/dag/convergence/controller.js +100 -3
- package/dist/workflows/dag/frontend-implementation-contract.js +6 -3
- package/dist/workflows/dag/frontend-prewrite-gate.js +6 -1
- package/dist/workflows/dag/init-hybrid.js +51 -11
- package/dist/workflows/dag/repair-artifact.js +100 -4
- package/dist/workflows/dag/rerun-plan.js +14 -7
- package/dist/workflows/dag/types.js +6 -0
- package/dist/workflows/dag/workspace-checkpoint.js +42 -0
- package/docs/templates/init-managed-agents.md +3 -3
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +13 -6
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
4
|
+
function normalizePath(value) {
|
|
5
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
6
|
+
}
|
|
7
|
+
function isConcreteRepoRelativePath(value) {
|
|
8
|
+
const normalized = normalizePath(value);
|
|
9
|
+
if (!normalized)
|
|
10
|
+
return false;
|
|
11
|
+
if (normalized === "." ||
|
|
12
|
+
normalized === "**" ||
|
|
13
|
+
normalized === "*" ||
|
|
14
|
+
normalized === "/*") {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (normalized.includes("**") || normalized.includes("*"))
|
|
18
|
+
return false;
|
|
19
|
+
if (path.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized))
|
|
20
|
+
return false;
|
|
21
|
+
if (normalized.split("/").some((segment) => segment === ".."))
|
|
22
|
+
return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
function assertValidDirectoryIndexRule(rule, indexLabel) {
|
|
26
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
|
27
|
+
throw new Error(`document-index-closure: invalid completeDirectoryIndexes entry at ${indexLabel}`);
|
|
28
|
+
}
|
|
29
|
+
const record = rule;
|
|
30
|
+
if (typeof record.directory !== "string" ||
|
|
31
|
+
record.directory.trim().length === 0) {
|
|
32
|
+
throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].directory must be a non-empty string`);
|
|
33
|
+
}
|
|
34
|
+
if (typeof record.index !== "string" || record.index.trim().length === 0) {
|
|
35
|
+
throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].index must be a non-empty string`);
|
|
36
|
+
}
|
|
37
|
+
const directory = normalizePath(record.directory);
|
|
38
|
+
const index = normalizePath(record.index);
|
|
39
|
+
if (!isConcreteRepoRelativePath(directory)) {
|
|
40
|
+
throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].directory must be a concrete repo-relative path (got "${record.directory}")`);
|
|
41
|
+
}
|
|
42
|
+
if (!isConcreteRepoRelativePath(index)) {
|
|
43
|
+
throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].index must be a concrete repo-relative path without globs (got "${record.index}")`);
|
|
44
|
+
}
|
|
45
|
+
return { directory, index };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Load completeDirectoryIndexes from docs/document-catalog.json only.
|
|
49
|
+
* Does not hard-code a second directory table.
|
|
50
|
+
*/
|
|
51
|
+
export function loadCompleteDirectoryIndexes(repoRoot) {
|
|
52
|
+
const catalogPath = path.join(repoRoot, "docs", "document-catalog.json");
|
|
53
|
+
// The catalog is an opt-in governance surface. Projects that have not
|
|
54
|
+
// initialized it must still be able to generate DAGs for docs paths.
|
|
55
|
+
if (!existsSync(catalogPath))
|
|
56
|
+
return [];
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = readFileSync(catalogPath, "utf-8");
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw new Error(`document-index-closure: failed to read docs/document-catalog.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
63
|
+
}
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(raw);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw new Error(`document-index-closure: docs/document-catalog.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
72
|
+
throw new Error("document-index-closure: docs/document-catalog.json root must be an object");
|
|
73
|
+
}
|
|
74
|
+
const rulesRaw = parsed
|
|
75
|
+
.completeDirectoryIndexes;
|
|
76
|
+
if (rulesRaw === undefined) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(rulesRaw)) {
|
|
80
|
+
throw new Error("document-index-closure: completeDirectoryIndexes must be an array when present");
|
|
81
|
+
}
|
|
82
|
+
return rulesRaw.map((rule, index) => assertValidDirectoryIndexRule(rule, String(index)));
|
|
83
|
+
}
|
|
84
|
+
function candidateDirectoryPrefixes(candidatePath) {
|
|
85
|
+
const normalized = normalizePath(candidatePath);
|
|
86
|
+
if (!normalized)
|
|
87
|
+
return [];
|
|
88
|
+
// Treat trailing /** globs as directory roots for prefix matching.
|
|
89
|
+
const withoutGlob = normalized
|
|
90
|
+
.replace(/\/\*\*$/, "")
|
|
91
|
+
.replace(/\/\*$/, "")
|
|
92
|
+
.replace(/\*\*$/, "")
|
|
93
|
+
.replace(/\*$/, "");
|
|
94
|
+
const base = normalizePath(withoutGlob);
|
|
95
|
+
if (!base || base === "." || base === "**")
|
|
96
|
+
return [];
|
|
97
|
+
const segments = base.split("/").filter(Boolean);
|
|
98
|
+
const prefixes = [];
|
|
99
|
+
for (let i = segments.length; i >= 1; i -= 1) {
|
|
100
|
+
prefixes.push(segments.slice(0, i).join("/"));
|
|
101
|
+
}
|
|
102
|
+
return prefixes;
|
|
103
|
+
}
|
|
104
|
+
function longestMatchingRule(candidatePath, rules) {
|
|
105
|
+
const prefixes = new Set(candidateDirectoryPrefixes(candidatePath));
|
|
106
|
+
if (prefixes.size === 0)
|
|
107
|
+
return undefined;
|
|
108
|
+
let best;
|
|
109
|
+
for (const rule of rules) {
|
|
110
|
+
if (!prefixes.has(rule.directory) &&
|
|
111
|
+
!candidateUnderDirectory(candidatePath, rule.directory)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!best || rule.directory.length > best.directory.length) {
|
|
115
|
+
best = rule;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return best;
|
|
119
|
+
}
|
|
120
|
+
function candidateUnderDirectory(candidatePath, directory) {
|
|
121
|
+
const normalized = normalizePath(candidatePath)
|
|
122
|
+
.replace(/\/\*\*$/, "")
|
|
123
|
+
.replace(/\/\*$/, "");
|
|
124
|
+
const dir = normalizePath(directory);
|
|
125
|
+
return normalized === dir || normalized.startsWith(`${dir}/`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Derive concrete companion index paths for candidate docs paths using catalog rules.
|
|
129
|
+
* Longest directory prefix wins. Throws fail-closed on forbidden conflicts.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveDocumentIndexCompanions(input) {
|
|
132
|
+
const forbidden = input.forbiddenPaths ?? [];
|
|
133
|
+
const companions = new Set();
|
|
134
|
+
for (const candidate of input.candidatePaths) {
|
|
135
|
+
const rule = longestMatchingRule(candidate, input.rules);
|
|
136
|
+
if (!rule)
|
|
137
|
+
continue;
|
|
138
|
+
const indexPath = rule.index;
|
|
139
|
+
if (forbidden.some((pattern) => pathMatchesPattern(indexPath, pattern))) {
|
|
140
|
+
throw new Error(`document-index-closure: companion index "${indexPath}" for "${candidate}" conflicts with forbiddenPaths`);
|
|
141
|
+
}
|
|
142
|
+
companions.add(indexPath);
|
|
143
|
+
}
|
|
144
|
+
return [...companions].sort((a, b) => a.localeCompare(b));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Convenience: load catalog and merge companion indexes into path lists.
|
|
148
|
+
*/
|
|
149
|
+
export function mergeDocumentIndexCompanions(input) {
|
|
150
|
+
const rules = loadCompleteDirectoryIndexes(input.repoRoot);
|
|
151
|
+
const companions = resolveDocumentIndexCompanions({
|
|
152
|
+
candidatePaths: input.paths,
|
|
153
|
+
rules,
|
|
154
|
+
forbiddenPaths: input.forbiddenPaths,
|
|
155
|
+
});
|
|
156
|
+
const merged = new Set(input.paths.map(normalizePath));
|
|
157
|
+
for (const companion of companions) {
|
|
158
|
+
merged.add(companion);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
paths: [...merged],
|
|
162
|
+
companions,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { redactSecrets, truncateUtf8Preview
|
|
3
|
+
import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
|
|
4
4
|
import { resolveDagNodeSkills } from "../../workflows/dag/skills.js";
|
|
5
5
|
import { resolveDagNodePromptRedacted, resolveDagRunJson, } from "./dag-run-artifacts.js";
|
|
6
6
|
export const NODE_INPUT_SCHEMA_VERSION = 1;
|
|
@@ -142,9 +142,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
142
142
|
? spec.defaults
|
|
143
143
|
: undefined;
|
|
144
144
|
const executorDeclared = typeof task.executor === "string";
|
|
145
|
-
const executor = executorDeclared
|
|
146
|
-
? String(task.executor)
|
|
147
|
-
: "pi";
|
|
145
|
+
const executor = executorDeclared ? String(task.executor) : "pi";
|
|
148
146
|
const executorSource = executorDeclared
|
|
149
147
|
? "task"
|
|
150
148
|
: "schema-default";
|
|
@@ -190,7 +188,9 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
190
188
|
const shellRaw = task.shell && typeof task.shell === "object" && !Array.isArray(task.shell)
|
|
191
189
|
? task.shell
|
|
192
190
|
: null;
|
|
193
|
-
const staticRaw = task.static &&
|
|
191
|
+
const staticRaw = task.static &&
|
|
192
|
+
typeof task.static === "object" &&
|
|
193
|
+
!Array.isArray(task.static)
|
|
194
194
|
? task.static
|
|
195
195
|
: null;
|
|
196
196
|
let kind = "unknown";
|
|
@@ -206,9 +206,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
206
206
|
preset: typeof shellRaw.preset === "string"
|
|
207
207
|
? redactSecrets(shellRaw.preset)
|
|
208
208
|
: null,
|
|
209
|
-
cwd: typeof shellRaw.cwd === "string"
|
|
210
|
-
? redactSecrets(shellRaw.cwd)
|
|
211
|
-
: null,
|
|
209
|
+
cwd: typeof shellRaw.cwd === "string" ? redactSecrets(shellRaw.cwd) : null,
|
|
212
210
|
timeoutMs: typeof shellRaw.timeoutMs === "number" &&
|
|
213
211
|
Number.isFinite(shellRaw.timeoutMs)
|
|
214
212
|
? shellRaw.timeoutMs
|
|
@@ -216,6 +214,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
216
214
|
nonZeroExitPolicy: typeof shellRaw.nonZeroExitPolicy === "string"
|
|
217
215
|
? shellRaw.nonZeroExitPolicy
|
|
218
216
|
: null,
|
|
217
|
+
failFast: typeof shellRaw.failFast === "boolean" ? shellRaw.failFast : null,
|
|
219
218
|
gates: shellGateNames(shellRaw),
|
|
220
219
|
}
|
|
221
220
|
: null;
|
|
@@ -245,9 +244,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
245
244
|
executor: redactString(executor),
|
|
246
245
|
executorSource,
|
|
247
246
|
role: typeof task.role === "string" ? redactSecrets(task.role) : null,
|
|
248
|
-
complexity: typeof task.complexity === "string"
|
|
249
|
-
? task.complexity
|
|
250
|
-
: null,
|
|
247
|
+
complexity: typeof task.complexity === "string" ? task.complexity : null,
|
|
251
248
|
writePolicy: writePolicy ? redactSecrets(writePolicy) : null,
|
|
252
249
|
writePolicySource,
|
|
253
250
|
toolProfile: typeof task.toolProfile === "string"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, appendFile, cp, mkdir } from "node:fs/promises";
|
|
1
|
+
import { access, appendFile, cp, mkdir, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseProcessVerdict } from "../node-execution.js";
|
|
4
4
|
import { freshNodeRecord } from "../dynamic-runtime/shared.js";
|
|
@@ -120,7 +120,8 @@ export async function runConvergencePassController(input) {
|
|
|
120
120
|
isLegitimateReviewRequestRevision(input.state)) {
|
|
121
121
|
return handleReviewRequestRevision(input, reviewGate);
|
|
122
122
|
}
|
|
123
|
-
if (reviewGate.status === "FINISHED"
|
|
123
|
+
if (reviewGate.status === "FINISHED" &&
|
|
124
|
+
isLegitimateReviewPass(input.state)) {
|
|
124
125
|
convergence.terminalReason = "review-pass";
|
|
125
126
|
await appendConvergenceKnowledgePattern({
|
|
126
127
|
cwd: input.cwd,
|
|
@@ -128,6 +129,9 @@ export async function runConvergencePassController(input) {
|
|
|
128
129
|
});
|
|
129
130
|
return { retry: false };
|
|
130
131
|
}
|
|
132
|
+
if (reviewGate.status === "FINISHED") {
|
|
133
|
+
return handleReviewRequestRevision(input, reviewGate);
|
|
134
|
+
}
|
|
131
135
|
// Compatibility: gates without routingAccept still ERROR on revision.
|
|
132
136
|
if (reviewGate.status === "ERROR") {
|
|
133
137
|
return handleReviewRequestRevision(input, reviewGate);
|
|
@@ -151,10 +155,14 @@ async function resolveNoRepairTerminal(input) {
|
|
|
151
155
|
isLegitimateReviewRequestRevision(input.state)) {
|
|
152
156
|
return handleReviewRequestRevision(input, reviewGate);
|
|
153
157
|
}
|
|
154
|
-
if (reviewGate.status === "FINISHED"
|
|
158
|
+
if (reviewGate.status === "FINISHED" &&
|
|
159
|
+
isLegitimateReviewPass(input.state)) {
|
|
155
160
|
convergence.terminalReason = "review-pass";
|
|
156
161
|
return { retry: false };
|
|
157
162
|
}
|
|
163
|
+
if (reviewGate.status === "FINISHED") {
|
|
164
|
+
return handleReviewRequestRevision(input, reviewGate);
|
|
165
|
+
}
|
|
158
166
|
if (reviewGate.status === "ERROR") {
|
|
159
167
|
return handleReviewRequestRevision(input, reviewGate);
|
|
160
168
|
}
|
|
@@ -178,10 +186,61 @@ async function resolveNoRepairTerminal(input) {
|
|
|
178
186
|
* Hard verification failed. Apply the existing non-retry / regression /
|
|
179
187
|
* max-passes guards, then reset the convergence chain for another pass.
|
|
180
188
|
*/
|
|
189
|
+
function repairDispositionOf(artifact) {
|
|
190
|
+
return artifact?.disposition ?? "repairable";
|
|
191
|
+
}
|
|
192
|
+
function hardVerifyFailureFingerprint(input) {
|
|
193
|
+
return [
|
|
194
|
+
input.failureCategory ?? "unknown",
|
|
195
|
+
input.verifyPhase ?? "",
|
|
196
|
+
input.verifyQuota ?? "",
|
|
197
|
+
String(input.verifyCommandCount ?? ""),
|
|
198
|
+
(input.verifyCommandLabels ?? []).join("\n"),
|
|
199
|
+
(input.stderrExcerpt ?? "").slice(0, 500),
|
|
200
|
+
].join("|");
|
|
201
|
+
}
|
|
202
|
+
function fingerprintFromHardVerify(hardVerify) {
|
|
203
|
+
const verifyEvidence = hardVerify.verifyEvidence;
|
|
204
|
+
// Keep fingerprint fields aligned with passHistory so consecutive passes
|
|
205
|
+
// compare the same identity (category + verifyEvidence command contract).
|
|
206
|
+
// stderr is intentionally excluded: pass history does not retain it, and
|
|
207
|
+
// empty repair manifests already prove no workspace progress.
|
|
208
|
+
return hardVerifyFailureFingerprint({
|
|
209
|
+
failureCategory: hardVerify.failureCategory,
|
|
210
|
+
verifyPhase: verifyEvidence?.phase,
|
|
211
|
+
verifyQuota: verifyEvidence?.quota,
|
|
212
|
+
verifyCommandCount: verifyEvidence?.commandCount,
|
|
213
|
+
verifyCommandLabels: verifyEvidence?.commandLabels,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function fingerprintFromPassHistory(pass) {
|
|
217
|
+
return hardVerifyFailureFingerprint({
|
|
218
|
+
failureCategory: pass.hardVerifyFailureCategory,
|
|
219
|
+
verifyPhase: pass.verifyPhase,
|
|
220
|
+
verifyQuota: pass.verifyQuota,
|
|
221
|
+
verifyCommandCount: pass.verifyCommandCount,
|
|
222
|
+
verifyCommandLabels: pass.verifyCommandLabels,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async function readRepairChangedFiles(runDir, repairNodeId = "repair-pi") {
|
|
226
|
+
try {
|
|
227
|
+
const raw = await readFile(path.join(runDir, repairNodeId, "change-manifest.json"), "utf-8");
|
|
228
|
+
const parsed = JSON.parse(raw);
|
|
229
|
+
if (!Array.isArray(parsed.changedFiles))
|
|
230
|
+
return [];
|
|
231
|
+
return parsed.changedFiles.filter((entry) => typeof entry === "string");
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
181
237
|
async function handleHardVerifyFailure(input, hardVerify) {
|
|
182
238
|
const convergence = input.state.convergence;
|
|
183
239
|
const currentPass = convergence.currentPass || 1;
|
|
184
240
|
const hardFailure = hardVerify.failureCategory ?? "unknown";
|
|
241
|
+
const processSupervisor = input.state.nodes["process-supervisor-pi"];
|
|
242
|
+
const repairArtifact = processSupervisor?.repairArtifact;
|
|
243
|
+
const disposition = repairDispositionOf(repairArtifact);
|
|
185
244
|
const passRecord = await buildConvergencePassRecord({
|
|
186
245
|
pass: currentPass,
|
|
187
246
|
status: "retrying",
|
|
@@ -198,6 +257,36 @@ async function handleHardVerifyFailure(input, hardVerify) {
|
|
|
198
257
|
await input.persistState();
|
|
199
258
|
return { retry: false };
|
|
200
259
|
}
|
|
260
|
+
// AC-002: blocked-boundary stops re-entry of same-permission repair.
|
|
261
|
+
if (disposition === "blocked-boundary") {
|
|
262
|
+
passRecord.status = "terminal";
|
|
263
|
+
passRecord.reason = "blocked-boundary";
|
|
264
|
+
convergence.passHistory.push(passRecord);
|
|
265
|
+
convergence.terminalReason = "blocked-boundary";
|
|
266
|
+
await input.persistState();
|
|
267
|
+
return { retry: false };
|
|
268
|
+
}
|
|
269
|
+
// AC-003: identical hard-verify failure fingerprint + empty repair
|
|
270
|
+
// change-manifest => no-progress early stop (does not exhaust maxFixLoops).
|
|
271
|
+
const currentFingerprint = fingerprintFromHardVerify(hardVerify);
|
|
272
|
+
const previousFailed = [...convergence.passHistory]
|
|
273
|
+
.reverse()
|
|
274
|
+
.find((pass) => pass.hardVerifyStatus === "ERROR");
|
|
275
|
+
const previousFingerprint = previousFailed
|
|
276
|
+
? fingerprintFromPassHistory(previousFailed)
|
|
277
|
+
: undefined;
|
|
278
|
+
const changedFiles = await readRepairChangedFiles(input.runDir);
|
|
279
|
+
if (previousFingerprint !== undefined &&
|
|
280
|
+
previousFingerprint === currentFingerprint &&
|
|
281
|
+
changedFiles !== undefined &&
|
|
282
|
+
changedFiles.length === 0) {
|
|
283
|
+
passRecord.status = "terminal";
|
|
284
|
+
passRecord.reason = "no-progress";
|
|
285
|
+
convergence.passHistory.push(passRecord);
|
|
286
|
+
convergence.terminalReason = "no-progress";
|
|
287
|
+
await input.persistState();
|
|
288
|
+
return { retry: false };
|
|
289
|
+
}
|
|
201
290
|
if (input.spec.convergence?.pauseOnRegression !== false &&
|
|
202
291
|
detectConvergenceRegression(convergence.passHistory, passRecord)) {
|
|
203
292
|
passRecord.status = "paused";
|
|
@@ -239,6 +328,14 @@ function isLegitimateReviewRequestRevision(state) {
|
|
|
239
328
|
reviewVerdictNode?.status === "FINISHED" &&
|
|
240
329
|
parseProcessVerdict(reviewVerdictNode) === "request-revision");
|
|
241
330
|
}
|
|
331
|
+
function isLegitimateReviewPass(state) {
|
|
332
|
+
const reviewNode = state.nodes["review-pi"];
|
|
333
|
+
const reviewVerdictNode = state.nodes[REVIEW_VERDICT_NODE_ID];
|
|
334
|
+
return (reviewNode?.status === "FINISHED" &&
|
|
335
|
+
parseProcessVerdict(reviewNode) === "pass" &&
|
|
336
|
+
reviewVerdictNode?.status === "FINISHED" &&
|
|
337
|
+
parseProcessVerdict(reviewVerdictNode) === "pass");
|
|
338
|
+
}
|
|
242
339
|
/**
|
|
243
340
|
* Hard verification passed but the review path produced a legitimate
|
|
244
341
|
* `request-revision`. With routingAccept the review gate finishes as a routing
|
|
@@ -155,7 +155,7 @@ export const frontendImplementationContractSchema = z
|
|
|
155
155
|
"not-needed",
|
|
156
156
|
]),
|
|
157
157
|
productionDefaultOff: z.literal(true),
|
|
158
|
-
activation: z.string().min(1),
|
|
158
|
+
activation: z.preprocess((value) => (value === "" ? "explicit activation boundary" : value), z.string().min(1)),
|
|
159
159
|
endpoints: z.array(z
|
|
160
160
|
.object({
|
|
161
161
|
method: z.enum([
|
|
@@ -168,7 +168,10 @@ export const frontendImplementationContractSchema = z
|
|
|
168
168
|
"OPTIONS",
|
|
169
169
|
]),
|
|
170
170
|
path: z.string().startsWith("/"),
|
|
171
|
-
fixture
|
|
171
|
+
// Models occasionally emit an empty fixture when Mock is
|
|
172
|
+
// intentionally not needed. Treat it like an omitted optional
|
|
173
|
+
// field; active Mock strategies still fail the refinement below.
|
|
174
|
+
fixture: z.preprocess((value) => (value === "" ? undefined : value), safePath.optional()),
|
|
172
175
|
consumer: safePath.optional(),
|
|
173
176
|
})
|
|
174
177
|
.strict()),
|
|
@@ -187,7 +190,7 @@ export const frontendImplementationContractSchema = z
|
|
|
187
190
|
type: z.enum(["static", "unit", "component", "integration", "mock"]),
|
|
188
191
|
commandLabel: z.string().min(1),
|
|
189
192
|
file: safePath,
|
|
190
|
-
symbol: z.string().min(1).optional(),
|
|
193
|
+
symbol: z.preprocess((value) => (value === "" ? undefined : value), z.string().min(1).optional()),
|
|
191
194
|
requirementIds: z.array(id),
|
|
192
195
|
uiStates: z.array(z.string().min(1)),
|
|
193
196
|
})
|
|
@@ -46,7 +46,12 @@ function firstNonEmptyVerdictLine(text) {
|
|
|
46
46
|
// The prompt requires VERDICT to be the first non-empty line, but model
|
|
47
47
|
// output can still prepend a summary. Keep the protocol strict in the
|
|
48
48
|
// prompt while making the deterministic gate resilient to that drift.
|
|
49
|
-
|
|
49
|
+
const verdictLine = normalizedLines.find((line) => /^VERDICT:/.test(line)) ?? first;
|
|
50
|
+
if (/^VERDICT:\s*pass(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
|
|
51
|
+
return "VERDICT: pass";
|
|
52
|
+
if (/^VERDICT:\s*request-revision(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
|
|
53
|
+
return "VERDICT: request-revision";
|
|
54
|
+
return verdictLine;
|
|
50
55
|
}
|
|
51
56
|
function eventArgs(event) {
|
|
52
57
|
return event.args ?? event.toolInput ?? event.input ?? {};
|
|
@@ -13,6 +13,7 @@ import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRU
|
|
|
13
13
|
import { REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL, REVIEW_VERDICT_OUTPUT_PROTOCOL, } from "./output-protocol.js";
|
|
14
14
|
import { resolveAdapter } from "../../adapters/index.js";
|
|
15
15
|
import { loadHarnessManifest } from "../../governance/harness.js";
|
|
16
|
+
import { mergeDocumentIndexCompanions } from "../../governance/document-index-closure.js";
|
|
16
17
|
import { buildAuthoritySurfaceAuditNode, buildAuthoritySurfaceGateNode, resolveAuthoritySurfaceAudit, } from "./authority-surface.js";
|
|
17
18
|
import { applySddEmbeddedEnhancements, probeRepoLocalSddSkills, } from "./sdd-embedded.js";
|
|
18
19
|
import { discoverProjectGovernancePresence } from "./project-governance-context.js";
|
|
@@ -721,9 +722,28 @@ function mergeForbiddenPaths(taskConfig) {
|
|
|
721
722
|
]);
|
|
722
723
|
return [...merged];
|
|
723
724
|
}
|
|
724
|
-
function resolveImplementPaths(taskConfig) {
|
|
725
|
+
function resolveImplementPaths(taskConfig, options = {}) {
|
|
725
726
|
if (taskConfig.allowedPaths.length > 0) {
|
|
726
727
|
const allowed = [...taskConfig.allowedPaths];
|
|
728
|
+
const forbidden = mergeForbiddenPaths(taskConfig);
|
|
729
|
+
const repoRoot = options.repoRoot;
|
|
730
|
+
// Only consult the catalog when writer paths can touch docs/* so non-docs
|
|
731
|
+
// temp fixtures without a catalog keep working; docs writers still fail closed.
|
|
732
|
+
const mayNeedDocIndex = allowed.some((entry) => {
|
|
733
|
+
const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
734
|
+
return normalized === "docs" || normalized.startsWith("docs/");
|
|
735
|
+
});
|
|
736
|
+
if (repoRoot && mayNeedDocIndex) {
|
|
737
|
+
const merged = mergeDocumentIndexCompanions({
|
|
738
|
+
repoRoot,
|
|
739
|
+
paths: allowed,
|
|
740
|
+
forbiddenPaths: forbidden,
|
|
741
|
+
});
|
|
742
|
+
return {
|
|
743
|
+
allowedPaths: merged.paths,
|
|
744
|
+
writeSet: [...merged.paths],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
727
747
|
return {
|
|
728
748
|
allowedPaths: allowed,
|
|
729
749
|
writeSet: [...allowed],
|
|
@@ -786,7 +806,9 @@ function applyMavenVerificationPlanning(input) {
|
|
|
786
806
|
if (!input.repoRoot || !input.commands || input.commands.length === 0) {
|
|
787
807
|
return { commands: input.commands };
|
|
788
808
|
}
|
|
789
|
-
const implement = resolveImplementPaths(input.taskConfig
|
|
809
|
+
const implement = resolveImplementPaths(input.taskConfig, {
|
|
810
|
+
repoRoot: input.repoRoot,
|
|
811
|
+
});
|
|
790
812
|
const planned = planMavenVerification({
|
|
791
813
|
repoRoot: input.repoRoot,
|
|
792
814
|
commands: input.commands,
|
|
@@ -1515,7 +1537,9 @@ function mergeFinalVerifyCommands(repoRoot, taskConfig, adapterCommands) {
|
|
|
1515
1537
|
export function buildStandardHybridDagFromTask(sources) {
|
|
1516
1538
|
const { taskConfig } = sources;
|
|
1517
1539
|
const forbiddenPaths = mergeForbiddenPaths(taskConfig);
|
|
1518
|
-
const implementPaths = resolveImplementPaths(taskConfig
|
|
1540
|
+
const implementPaths = resolveImplementPaths(taskConfig, {
|
|
1541
|
+
repoRoot: sources.repoRoot,
|
|
1542
|
+
});
|
|
1519
1543
|
const scoutPaths = deriveParallelScoutPaths(taskConfig);
|
|
1520
1544
|
const scoutComplexity = mapTaskComplexity(taskConfig.complexity);
|
|
1521
1545
|
const implementComplexity = resolveWriterComplexity(taskConfig);
|
|
@@ -2076,7 +2100,9 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2076
2100
|
frontendMockMode: mockMode,
|
|
2077
2101
|
};
|
|
2078
2102
|
const forbiddenPaths = mergeForbiddenPaths(taskConfig);
|
|
2079
|
-
const implementPaths = resolveImplementPaths(taskConfig
|
|
2103
|
+
const implementPaths = resolveImplementPaths(taskConfig, {
|
|
2104
|
+
repoRoot: sources.repoRoot,
|
|
2105
|
+
});
|
|
2080
2106
|
const implementId = frontendImplementationNodeId();
|
|
2081
2107
|
const mockContextBlock = resolveFrontendMockContextBlock(frontendSources);
|
|
2082
2108
|
const capabilityContextBlock = resolveFrontendCapabilityContextBlock(frontendSources);
|
|
@@ -3600,7 +3626,7 @@ async function buildBackendTestHybridDag(sources) {
|
|
|
3600
3626
|
outputContract: "Convert every final automatable Markdown case into pytest assets whose actual test function region contains the exact Case ID, preferably in the function name or docstring. Each testcase/md/<module>.md (excluding README.md) maps one-to-one to testcase/test_<module>.py; never merge or split modules. No JSON and no pytest execution.",
|
|
3601
3627
|
subtask_prompt: [
|
|
3602
3628
|
"Convert testcase/md/** to pytest using upstream environment and advisory validation evidence plus only bounded pytest config/conftest. A FAIL advisory report does not authorize inventing missing behavior; use the final Markdown facts that are present.",
|
|
3603
|
-
|
|
3629
|
+
'Ensure every final Markdown Case ID appears in exactly one primary pytest test function or pytest test class method region, using the exact `primary symbol` declared by Markdown. The symbol must start with `test_BE_<MODULE>_<NNN>_` so every parameterized collected item remains associated with its Case. Module-level functions and class-based pytest methods are both supported. Only `变体测试点` may use stable `pytest.param(..., id="TP-...")` IDs, and every atomic variant ID must appear exactly once with a genuine input/state/outcome change. Use `pytest.param(..., id=...)` for every row; do not use decorator-level `ids=[...]`, generated suffixes, or IDs that extend/shorten the exact Markdown TP. Do not parameterize `场景断言测试点` or `横切证据测试点`; execute all assertion checkpoints within the same business journey/item and use shared helpers for cross-cutting evidence. The primary symbol docstring must contain exact metadata lines `Case-ID: BE-...`, `Assertion-Test-Points: TP-...;TP-...` and `Cross-Cutting-Test-Points: TP-...;TP-...` (use `none` when empty). No Test Point may be invented, renamed, omitted or bound in two modes. The generated pytest collection shape must equal the Markdown prediction `sum(max(1, variant count per Case))`; keep it at or below the task\'s explicit budget by removing duplicate execution, never by collapsing multiple parameter rows under a coarse family TP. Assertions come only from 预期结果 and setup comes only from 前置条件/测试数据/自动化映射.',
|
|
3604
3630
|
"Name each generated pytest file so it corresponds one-to-one with its source Markdown module file: for each `testcase/md/<module>.md` (excluding README.md), emit exactly one `testcase/test_<module>.py`. The <module> stem is the Markdown filename without the `.md` extension, lowercased and with non-alphanumeric characters replaced by underscores. For example, `testcase/md/resource_notes.md` maps to `testcase/test_resource_notes.py`, `testcase/md/health.md` maps to `testcase/test_health.py`, `testcase/md/BE-HEALTH.md` maps to `testcase/test_be_health.py`, and `testcase/md/order-api.md` maps to `testcase/test_order_api.py`. If Markdown automation mapping names a different path than this module stem path, still write the module stem path and do not invent prefixes such as `test_be_*` unless the module filename itself normalizes to that stem. Never merge multiple Markdown modules into one pytest file, never split one module across several files, and never invent pytest filenames unrelated to the Markdown modules.",
|
|
3605
3631
|
"Generate a reusable HTTP logging helper (or equivalent client wrapper) and call it for every interface request. The request log must include method, URL/path, and request parameters (query plus JSON/body/payload summary). The response log must include status code and response result (JSON/text/body summary), and both records must be visible in pytest stdout/stderr without changing assertions.",
|
|
3606
3632
|
"HTTP response header names are case-insensitive. If the helper stores a lower-case normalized header map, every Content-Type or other header assertion must query the lower-case key (for example `content-type`) or use an explicitly case-insensitive accessor; never call a case-sensitive plain dict with `Content-Type` when the stored key is lower-case. Preserve the actual media-type assertion rather than dropping it.",
|
|
@@ -4139,12 +4165,20 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
4139
4165
|
forbiddenPaths: forbidden,
|
|
4140
4166
|
outputContract: "Deterministic frontend L-5 Markdown and self-contained HTML dashboard derived only from frontend-test-result-v1.",
|
|
4141
4167
|
subtask_prompt: "Render the authoritative frontend L-5 report from frontend-test-result-v1. Do not use Pi prose or invent code coverage. Missing line/branch coverage remains unavailable and makes L-5 NOT READY.",
|
|
4142
|
-
shell: {
|
|
4168
|
+
shell: {
|
|
4169
|
+
frontendTestL5Report: {},
|
|
4170
|
+
commands: [],
|
|
4171
|
+
cwd: ".",
|
|
4172
|
+
timeoutMs: 120000,
|
|
4173
|
+
},
|
|
4143
4174
|
});
|
|
4144
4175
|
if (strictOutcomeGate) {
|
|
4145
4176
|
tasks.push({
|
|
4146
4177
|
id: "frontend-test-result-outcome-gate-shell",
|
|
4147
|
-
depends_on: [
|
|
4178
|
+
depends_on: [
|
|
4179
|
+
"materialize-frontend-test-result-shell",
|
|
4180
|
+
"frontend-test-l5-report-shell",
|
|
4181
|
+
],
|
|
4148
4182
|
role: "verifier",
|
|
4149
4183
|
executor: "shell",
|
|
4150
4184
|
complexity: "LOW",
|
|
@@ -4162,7 +4196,10 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
4162
4196
|
}
|
|
4163
4197
|
tasks.push({
|
|
4164
4198
|
id: "frontend-test-retrospect-pi",
|
|
4165
|
-
depends_on: [
|
|
4199
|
+
depends_on: [
|
|
4200
|
+
"materialize-frontend-test-result-shell",
|
|
4201
|
+
"frontend-test-l5-report-shell",
|
|
4202
|
+
],
|
|
4166
4203
|
role: "closeout",
|
|
4167
4204
|
executor: "pi",
|
|
4168
4205
|
toolProfile: "write",
|
|
@@ -5393,7 +5430,7 @@ function buildReviewVerdictRecoveryNode(sources) {
|
|
|
5393
5430
|
writePolicy: "read-only",
|
|
5394
5431
|
allowedPaths: commonReadOnlyPaths(sources),
|
|
5395
5432
|
forbiddenPaths: commonForbiddenPaths(sources),
|
|
5396
|
-
outputContract:
|
|
5433
|
+
outputContract: "Structured JSON review verdict only, preserving the original review conclusion/findings without substantive changes. No file writes.",
|
|
5397
5434
|
outputProtocol: REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL,
|
|
5398
5435
|
subtask_prompt: [
|
|
5399
5436
|
"Normalize the output format of review-pi; this is the single read-only format-recovery attempt for the review verdict protocol.",
|
|
@@ -5748,8 +5785,9 @@ function buildProcessSupervisorNode(sources) {
|
|
|
5748
5785
|
"Supervise the implementation process after soft verification.",
|
|
5749
5786
|
"First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
|
|
5750
5787
|
"Immediately after the verdict, include one fenced block labelled REPAIR_ARTIFACT_JSON with this JSON shape:",
|
|
5751
|
-
'{"schemaVersion":1,"verdict":"pass|request-revision","failureClass":"syntax|runtime|logic|boundary|environment|governance|unknown","rootCause":"one concise reason","fixScope":["path/or/component"],"invariant":"behavior or contract to preserve","evidenceRefs":["relative/path/or/node"],"rawLogFallbackAllowed":false}',
|
|
5788
|
+
'{"schemaVersion":1,"verdict":"pass|request-revision","failureClass":"syntax|runtime|logic|boundary|environment|governance|unknown","rootCause":"one concise reason","fixScope":["path/or/component"],"invariant":"behavior or contract to preserve","evidenceRefs":["relative/path/or/node"],"rawLogFallbackAllowed":false,"disposition":"repairable|blocked-boundary|no-op-pass","requiredScope":["out-of-boundary/path"]}',
|
|
5752
5789
|
`For request-revision, fixScope must be inside ${repairId} allowedPaths/writeSet. For pass, use an empty fixScope array.`,
|
|
5790
|
+
"disposition defaults to repairable when omitted (backward compatible). Use blocked-boundary when the needed fix is outside repair write authority; put the out-of-boundary paths in requiredScope (report-only, never expands write authority). Use no-op-pass only when no code change is warranted.",
|
|
5753
5791
|
`Audit boundary drift, verification gaps, and whether ${repairId} should perform bounded fixes. Read-only: do not modify files.`,
|
|
5754
5792
|
buildSourceContextBlock(sources),
|
|
5755
5793
|
].join("\n\n"),
|
|
@@ -5785,7 +5823,9 @@ function buildProcessGateNode(sources) {
|
|
|
5785
5823
|
};
|
|
5786
5824
|
}
|
|
5787
5825
|
function buildRepairNode(sources) {
|
|
5788
|
-
const implement = resolveImplementPaths(sources.taskConfig
|
|
5826
|
+
const implement = resolveImplementPaths(sources.taskConfig, {
|
|
5827
|
+
repoRoot: sources.repoRoot,
|
|
5828
|
+
});
|
|
5789
5829
|
return {
|
|
5790
5830
|
id: repairNodeId(),
|
|
5791
5831
|
depends_on: ["process-gate-shell", "process-supervisor-pi"],
|