@tea-agent/loop-agent 0.16.24 → 0.16.26
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/cli/command-definitions.js +13 -0
- package/dist/cli/program.js +4 -0
- package/dist/commands/coverage-report.js +50 -0
- package/dist/executors/dag-pi-executor.js +63 -9
- package/dist/executors/shell-executor.js +30 -0
- package/dist/executors/shell-write-guard.js +64 -2
- package/dist/worker/delivery/git-transaction.js +43 -8
- package/dist/worker/observe/paths.js +81 -0
- package/dist/worker/observe/routes.js +127 -19
- package/dist/worker/observe/spec-evidence.js +84 -0
- package/dist/worker/observe/static/api.js +23 -0
- package/dist/worker/observe/static/state.js +26 -0
- package/dist/worker/observe/static/styles.css +10 -0
- package/dist/worker/observe/static/views/dag-inspector.js +173 -6
- package/dist/workflows/dag/backend-test-coverage-contract.js +202 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +84 -18
- package/dist/workflows/dag/backend-test-stability-contract.js +57 -0
- package/dist/workflows/dag/init-hybrid.js +150 -13
- package/dist/workflows/dag/l5-report-metrics.js +36 -0
- package/dist/workflows/dag/node-execution.js +32 -5
- package/dist/workflows/dag/project-governance-context.js +508 -0
- package/dist/workflows/dag/prompt.js +46 -1
- package/dist/workflows/dag/skill-snapshot.js +1 -0
- package/dist/workflows/dag/types.js +10 -0
- package/dist/workflows/dag/validate.js +28 -0
- package/docs/architecture/evolution.md +3 -1
- package/docs/templates/agent-dag.schema.json +15 -0
- package/docs/templates/agent-dag.supervised-implementation.json +1 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +2 -0
- package/docs/templates/backend-test-dag.json +39 -6
- package/docs/templates/backend-test-dag.retrospect.prompt.md +36 -7
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export const CODE_COVERAGE_SCHEMA_ID = "code-coverage-v1";
|
|
6
|
+
const relativePath = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
|
|
7
|
+
!value.includes("\\") &&
|
|
8
|
+
!value.split("/").some((part) => part === "" || part === "." || part === ".."), "path must be a relative POSIX path");
|
|
9
|
+
export const coverageMetricSchema = z.object({
|
|
10
|
+
status: z.enum(["available", "unavailable"]),
|
|
11
|
+
covered: z.number().int().min(0).nullable(),
|
|
12
|
+
total: z.number().int().min(0).nullable(),
|
|
13
|
+
ratio: z.number().min(0).max(1).nullable(),
|
|
14
|
+
reason: z.string().min(1).nullable(),
|
|
15
|
+
}).strict();
|
|
16
|
+
export const codeCoverageContractSchema = z.object({
|
|
17
|
+
schemaVersion: z.literal(1),
|
|
18
|
+
schemaId: z.literal(CODE_COVERAGE_SCHEMA_ID),
|
|
19
|
+
language: z.enum(["python", "java"]),
|
|
20
|
+
format: z.enum(["coverage.py-json", "jacoco-xml"]),
|
|
21
|
+
tool: z.string().min(1),
|
|
22
|
+
toolVersion: z.string().min(1).nullable(),
|
|
23
|
+
sourceScope: z.object({
|
|
24
|
+
requirementIds: z.array(z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/)),
|
|
25
|
+
paths: z.array(relativePath),
|
|
26
|
+
}).strict(),
|
|
27
|
+
commitSha: z.string().regex(/^[a-f0-9]{7,64}$/).nullable(),
|
|
28
|
+
metrics: z.object({
|
|
29
|
+
line: coverageMetricSchema,
|
|
30
|
+
branch: coverageMetricSchema,
|
|
31
|
+
function: coverageMetricSchema,
|
|
32
|
+
}).strict(),
|
|
33
|
+
artifact: z.object({
|
|
34
|
+
path: relativePath,
|
|
35
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
36
|
+
}).strict(),
|
|
37
|
+
missingData: z.array(z.string().min(1)),
|
|
38
|
+
}).strict();
|
|
39
|
+
function metric(covered, total, reason) {
|
|
40
|
+
if (covered === null || total === null || total === 0) {
|
|
41
|
+
return { status: "unavailable", covered, total, ratio: null, reason: reason ?? (total === 0 ? "denominator-is-zero" : "metric-missing") };
|
|
42
|
+
}
|
|
43
|
+
return { status: "available", covered, total, ratio: covered / total, reason: null };
|
|
44
|
+
}
|
|
45
|
+
function numberValue(value) {
|
|
46
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
47
|
+
}
|
|
48
|
+
function safeArtifactPath(value) {
|
|
49
|
+
const normalized = value.replaceAll(path.sep, "/");
|
|
50
|
+
if (path.posix.isAbsolute(normalized) || normalized.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
51
|
+
throw new Error("artifact path must be a safe relative POSIX path");
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
function scopeMissing(scope) {
|
|
56
|
+
return scope.requirementIds.length === 0 ? ["source-scope-requirement-id-missing"] : [];
|
|
57
|
+
}
|
|
58
|
+
function baseContract(options, common) {
|
|
59
|
+
return {
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
schemaId: CODE_COVERAGE_SCHEMA_ID,
|
|
62
|
+
...common,
|
|
63
|
+
sourceScope: {
|
|
64
|
+
requirementIds: [...options.sourceScope.requirementIds],
|
|
65
|
+
paths: [...options.sourceScope.paths],
|
|
66
|
+
},
|
|
67
|
+
commitSha: options.commitSha,
|
|
68
|
+
artifact: { path: safeArtifactPath(options.artifactPath), sha256: options.artifactSha256 },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function parsePythonTotals(input, sourceScope) {
|
|
72
|
+
if (!input || typeof input !== "object")
|
|
73
|
+
throw new Error("coverage.py JSON must be an object");
|
|
74
|
+
const root = input;
|
|
75
|
+
const totals = root.totals;
|
|
76
|
+
if (!totals || typeof totals !== "object")
|
|
77
|
+
throw new Error("coverage.py JSON missing totals");
|
|
78
|
+
const files = root.files;
|
|
79
|
+
const scopedFiles = sourceScope.paths.length > 0 && files
|
|
80
|
+
? Object.entries(files).filter(([file]) => sourceScope.paths.includes(file.replaceAll("\\", "/")))
|
|
81
|
+
: [];
|
|
82
|
+
const usesScopedFiles = sourceScope.paths.length > 0;
|
|
83
|
+
const values = usesScopedFiles ? scopedFiles.map(([, value]) => value) : null;
|
|
84
|
+
const get = (key) => {
|
|
85
|
+
if (values) {
|
|
86
|
+
let sum = 0;
|
|
87
|
+
for (const file of values) {
|
|
88
|
+
const data = file && typeof file === "object" ? file : {};
|
|
89
|
+
const value = numberValue(data[key]);
|
|
90
|
+
if (value !== null)
|
|
91
|
+
sum += value;
|
|
92
|
+
}
|
|
93
|
+
return sum;
|
|
94
|
+
}
|
|
95
|
+
if (usesScopedFiles)
|
|
96
|
+
return null;
|
|
97
|
+
return numberValue(totals[key]);
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
line: metric(get("covered_lines"), get("num_statements"), null),
|
|
101
|
+
branch: metric(get("covered_branches"), get("num_branches"), "coverage.py branch data missing"),
|
|
102
|
+
function: { status: "unavailable", covered: null, total: null, ratio: null, reason: "coverage.py JSON has no function coverage" },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function parseJacocoCounters(xml) {
|
|
106
|
+
const report = xml.match(/<report\b[^>]*>([\s\S]*?)<\/report>/i)?.[1];
|
|
107
|
+
if (report === undefined)
|
|
108
|
+
throw new Error("JaCoCo XML missing report root");
|
|
109
|
+
const reportLevel = report.split(/<(?:group|package)\b/i)[0] ?? "";
|
|
110
|
+
const counters = new Map();
|
|
111
|
+
for (const match of reportLevel.matchAll(/<counter\s+[^>]*type="([A-Z]+)"[^>]*missed="(\d+)"[^>]*covered="(\d+)"[^>]*\/>/gi)) {
|
|
112
|
+
const missed = Number(match[2]);
|
|
113
|
+
const covered = Number(match[3]);
|
|
114
|
+
counters.set(match[1].toUpperCase(), { covered, total: covered + missed });
|
|
115
|
+
}
|
|
116
|
+
return counters;
|
|
117
|
+
}
|
|
118
|
+
export function parseCoveragePyJson(input, options) {
|
|
119
|
+
const root = input;
|
|
120
|
+
const totals = parsePythonTotals(input, options.sourceScope);
|
|
121
|
+
const missingData = [...scopeMissing(options.sourceScope)];
|
|
122
|
+
const files = input && typeof input === "object" ? input.files : undefined;
|
|
123
|
+
if (options.sourceScope.paths.length > 0 && (!files || !options.sourceScope.paths.some((file) => Object.hasOwn(files, file))))
|
|
124
|
+
missingData.push("source-scope-not-found-in-artifact");
|
|
125
|
+
if (totals.branch.status === "unavailable")
|
|
126
|
+
missingData.push("branch-coverage-unavailable");
|
|
127
|
+
missingData.push("function-coverage-unavailable");
|
|
128
|
+
return codeCoverageContractSchema.parse({
|
|
129
|
+
...baseContract(options, {
|
|
130
|
+
language: "python",
|
|
131
|
+
format: "coverage.py-json",
|
|
132
|
+
tool: "coverage.py",
|
|
133
|
+
toolVersion: typeof root?.meta?.version === "string" ? root.meta.version : options.toolVersion ?? null,
|
|
134
|
+
}),
|
|
135
|
+
metrics: totals,
|
|
136
|
+
missingData,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
export function parseJacocoXml(xml, options) {
|
|
140
|
+
if (!/<report\b/i.test(xml))
|
|
141
|
+
throw new Error("JaCoCo XML missing report root");
|
|
142
|
+
const counters = parseJacocoCounters(xml);
|
|
143
|
+
const line = counters.get("LINE");
|
|
144
|
+
const branch = counters.get("BRANCH");
|
|
145
|
+
const method = counters.get("METHOD");
|
|
146
|
+
const missingData = [...scopeMissing(options.sourceScope)];
|
|
147
|
+
if (!line)
|
|
148
|
+
missingData.push("line-coverage-unavailable");
|
|
149
|
+
if (!branch)
|
|
150
|
+
missingData.push("branch-coverage-unavailable");
|
|
151
|
+
if (!method)
|
|
152
|
+
missingData.push("method-coverage-unavailable");
|
|
153
|
+
return codeCoverageContractSchema.parse({
|
|
154
|
+
...baseContract(options, {
|
|
155
|
+
language: "java",
|
|
156
|
+
format: "jacoco-xml",
|
|
157
|
+
tool: "JaCoCo",
|
|
158
|
+
toolVersion: options.toolVersion ?? null,
|
|
159
|
+
}),
|
|
160
|
+
metrics: {
|
|
161
|
+
line: line ? metric(line.covered, line.total, null) : metric(null, null, "JaCoCo LINE counter missing"),
|
|
162
|
+
branch: branch ? metric(branch.covered, branch.total, null) : metric(null, null, "JaCoCo BRANCH counter missing"),
|
|
163
|
+
function: method ? metric(method.covered, method.total, null) : metric(null, null, "JaCoCo METHOD counter missing"),
|
|
164
|
+
},
|
|
165
|
+
missingData,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
export async function readCoverageArtifact(inputPath, options) {
|
|
169
|
+
const raw = await readFile(inputPath, "utf8");
|
|
170
|
+
const artifactSha256 = createHash("sha256").update(raw).digest("hex");
|
|
171
|
+
const artifactPath = options.artifactPath ?? path.basename(inputPath);
|
|
172
|
+
const parseOptions = { ...options, artifactPath, artifactSha256 };
|
|
173
|
+
if (options.sourceScope.paths.some((value) => value.includes("..")))
|
|
174
|
+
throw new Error("unsafe source scope path");
|
|
175
|
+
if (inputPath.toLowerCase().endsWith(".xml"))
|
|
176
|
+
return parseJacocoXml(raw, parseOptions);
|
|
177
|
+
return parseCoveragePyJson(JSON.parse(raw), parseOptions);
|
|
178
|
+
}
|
|
179
|
+
export function formatCoverageMarkdown(contract) {
|
|
180
|
+
const row = (name, value) => `| ${name} | ${value.covered ?? "—"} | ${value.total ?? "—"} | ${value.ratio === null ? "unavailable" : `${(value.ratio * 100).toFixed(2)}%`} | ${value.status} | ${value.reason ?? "—"} |`;
|
|
181
|
+
return [
|
|
182
|
+
"# Code Coverage Report",
|
|
183
|
+
"",
|
|
184
|
+
`- Schema: ${contract.schemaId} v${contract.schemaVersion}`,
|
|
185
|
+
`- Language: ${contract.language}`,
|
|
186
|
+
`- Tool: ${contract.tool}${contract.toolVersion ? ` ${contract.toolVersion}` : ""}`,
|
|
187
|
+
`- Commit: ${contract.commitSha ?? "unavailable"}`,
|
|
188
|
+
`- Requirement IDs: ${contract.sourceScope.requirementIds.join(", ") || "unavailable"}`,
|
|
189
|
+
`- Source scope: ${contract.sourceScope.paths.join(", ") || "unavailable"}`,
|
|
190
|
+
`- Artifact: ${contract.artifact.path}`,
|
|
191
|
+
`- Artifact SHA-256: ${contract.artifact.sha256}`,
|
|
192
|
+
"",
|
|
193
|
+
"| Metric | Covered | Total | Ratio | Status | Reason |",
|
|
194
|
+
"|---|---:|---:|---:|---|---|",
|
|
195
|
+
row("Line", contract.metrics.line),
|
|
196
|
+
row("Branch", contract.metrics.branch),
|
|
197
|
+
row("Function/Method", contract.metrics.function),
|
|
198
|
+
"",
|
|
199
|
+
`Missing data: ${contract.missingData.length ? contract.missingData.join(", ") : "none"}`,
|
|
200
|
+
"",
|
|
201
|
+
].join("\n");
|
|
202
|
+
}
|
|
@@ -334,6 +334,41 @@ function asRecord(value) {
|
|
|
334
334
|
* Prefer exact schema payloads; otherwise map common discovery shapes onto the
|
|
335
335
|
* pytest-centric runtime contract without inventing secrets or managed commands.
|
|
336
336
|
*/
|
|
337
|
+
/** True when managed start is a local node/npm process the adapter cannot host. */
|
|
338
|
+
export function isLocalManagedServerStart(start) {
|
|
339
|
+
if (!start)
|
|
340
|
+
return false;
|
|
341
|
+
const normalized = start.trim().toLowerCase();
|
|
342
|
+
if (!normalized)
|
|
343
|
+
return false;
|
|
344
|
+
return (/\bnpm(\s+run)?\s+start\b/.test(normalized) ||
|
|
345
|
+
/\bnode\s+server(\.js)?\b/.test(normalized) ||
|
|
346
|
+
/\bnode\s+\.\/?server(\.js)?\b/.test(normalized) ||
|
|
347
|
+
/\bbash\s+scripts\/fe-test-server\.sh\b/.test(normalized) ||
|
|
348
|
+
normalized === "node server.js" ||
|
|
349
|
+
normalized.includes("server.js"));
|
|
350
|
+
}
|
|
351
|
+
function seedLocalServerFixture(fixtures, managedStart) {
|
|
352
|
+
if (Array.isArray(fixtures) && fixtures.length > 0) {
|
|
353
|
+
return fixtures.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item)));
|
|
354
|
+
}
|
|
355
|
+
if (managedStart.includes("server") || managedStart.includes("npm")) {
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
name: "server-bootstrap",
|
|
359
|
+
sourcePath: "server.js",
|
|
360
|
+
kind: "server-bootstrap",
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
return [
|
|
365
|
+
{
|
|
366
|
+
name: "pytest-test-root",
|
|
367
|
+
sourcePath: BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT,
|
|
368
|
+
kind: "test-root",
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
}
|
|
337
372
|
/** Drop empty managedCommand strings; omit block when not managed-command. */
|
|
338
373
|
export function sanitizeBackendTestExecutionInput(value) {
|
|
339
374
|
const record = asRecord(value);
|
|
@@ -359,6 +394,51 @@ export function sanitizeBackendTestExecutionInput(value) {
|
|
|
359
394
|
next.managedCommand = cleaned;
|
|
360
395
|
}
|
|
361
396
|
}
|
|
397
|
+
// Adapter always executes `python -m pytest testcase/`. Scout-chosen relative roots such as
|
|
398
|
+
// tests/api/** are product sample trees, not the frozen automation root. Absolute / ..
|
|
399
|
+
// paths stay untouched so schema materialize remains fail-closed.
|
|
400
|
+
if (typeof next.testRoot === "string" && next.testRoot.trim()) {
|
|
401
|
+
const rawRoot = next.testRoot.trim();
|
|
402
|
+
const normalizedRoot = rawRoot.replace(/\/+$/, "");
|
|
403
|
+
const looksUnsafe = normalizedRoot.startsWith("/") ||
|
|
404
|
+
normalizedRoot.includes("..") ||
|
|
405
|
+
normalizedRoot.includes("\\") ||
|
|
406
|
+
/^[A-Za-z]:/.test(normalizedRoot);
|
|
407
|
+
if (!looksUnsafe &&
|
|
408
|
+
normalizedRoot !== BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT) {
|
|
409
|
+
const gaps = Array.isArray(next.evidenceGaps)
|
|
410
|
+
? [...next.evidenceGaps]
|
|
411
|
+
: [];
|
|
412
|
+
gaps.push({
|
|
413
|
+
description: `scout testRoot=${normalizedRoot} remapped to frozen adapter root ${BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT}`,
|
|
414
|
+
sourceRef: "adapter:backend-test-execution",
|
|
415
|
+
});
|
|
416
|
+
next.evidenceGaps = gaps;
|
|
417
|
+
next.testRoot = BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Local node/npm managed-command is not hosted by the clean-env pytest shell.
|
|
421
|
+
// Demote to in-process so generate-pytest must bootstrap the server inside fixtures
|
|
422
|
+
// instead of requiring host-injected base URL env vars that clean env cannot provide.
|
|
423
|
+
const managedStart = asRecord(next.managedCommand) &&
|
|
424
|
+
typeof asRecord(next.managedCommand).start === "string"
|
|
425
|
+
? String(asRecord(next.managedCommand).start)
|
|
426
|
+
: "";
|
|
427
|
+
if (next.targetMode === "managed-command" &&
|
|
428
|
+
isLocalManagedServerStart(managedStart)) {
|
|
429
|
+
next.targetMode = "in-process";
|
|
430
|
+
next.requiredEnvNames = [];
|
|
431
|
+
next.existingFixtures = seedLocalServerFixture(Array.isArray(next.existingFixtures) ? next.existingFixtures : null, managedStart);
|
|
432
|
+
const gaps = Array.isArray(next.evidenceGaps)
|
|
433
|
+
? [...next.evidenceGaps]
|
|
434
|
+
: [];
|
|
435
|
+
gaps.push({
|
|
436
|
+
description: "local managed-command demoted to in-process: clean-env pytest shell does not start npm/node servers or inject base URL env; tests must bootstrap via fixtures",
|
|
437
|
+
sourceRef: asRecord(next.managedCommand)?.sourceRef ||
|
|
438
|
+
"adapter:backend-test-execution",
|
|
439
|
+
});
|
|
440
|
+
next.evidenceGaps = gaps;
|
|
441
|
+
}
|
|
362
442
|
// Near-schema scouts sometimes emit in-process with existingFixtures: [].
|
|
363
443
|
// Only rewrite near-schema payloads here; free-form envelopes keep empty/missing
|
|
364
444
|
// fixtures so coerceBackendTestExecutionInput can map discoveredFixtures first.
|
|
@@ -369,23 +449,7 @@ export function sanitizeBackendTestExecutionInput(value) {
|
|
|
369
449
|
if (nearSchema &&
|
|
370
450
|
(next.targetMode === "in-process" || next.targetMode === undefined) &&
|
|
371
451
|
(!fixtures || fixtures.length === 0)) {
|
|
372
|
-
|
|
373
|
-
typeof asRecord(next.managedCommand).start === "string"
|
|
374
|
-
? String(asRecord(next.managedCommand).start)
|
|
375
|
-
: "";
|
|
376
|
-
next.existingFixtures = [
|
|
377
|
-
managedStart.includes("server")
|
|
378
|
-
? {
|
|
379
|
-
name: "server-bootstrap",
|
|
380
|
-
sourcePath: "server.js",
|
|
381
|
-
kind: "server-bootstrap",
|
|
382
|
-
}
|
|
383
|
-
: {
|
|
384
|
-
name: "pytest-test-root",
|
|
385
|
-
sourcePath: BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT,
|
|
386
|
-
kind: "test-root",
|
|
387
|
-
},
|
|
388
|
-
];
|
|
452
|
+
next.existingFixtures = seedLocalServerFixture(fixtures, managedStart);
|
|
389
453
|
if (next.targetMode === undefined)
|
|
390
454
|
next.targetMode = "in-process";
|
|
391
455
|
}
|
|
@@ -631,10 +695,12 @@ export function buildBackendTestExecutionPreflightShellSnippet(options) {
|
|
|
631
695
|
`console.log("backend-test preflight ok: framework=pytest testRoot="+testRoot+" targetMode="+contract.targetMode);'`,
|
|
632
696
|
' "${CONTRACT}"',
|
|
633
697
|
].join("");
|
|
698
|
+
// Fail-closed: join with && so a preflight exit 2 never continues into pytest.
|
|
699
|
+
// Historical ";" chains recorded I27-style false progresses (env missing + pytest ran).
|
|
634
700
|
return [
|
|
635
701
|
'test -n "${HARNESS_DAG_RUN_DIR:-}" || { echo "missing HARNESS_DAG_RUN_DIR for backend pytest preflight" >&2; exit 2; }',
|
|
636
702
|
`CONTRACT="\${HARNESS_DAG_RUN_DIR}/${contractRelativePath}"`,
|
|
637
703
|
'test -f "${CONTRACT}" || { echo "missing backend-test execution contract: ${CONTRACT}" >&2; exit 2; }',
|
|
638
704
|
nodePreflight,
|
|
639
|
-
].join("
|
|
705
|
+
].join(" && ");
|
|
640
706
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const STABILITY_EVIDENCE_SCHEMA_ID = "stability-evidence-v1";
|
|
3
|
+
const runRefSchema = z.object({
|
|
4
|
+
runId: z.string().min(1),
|
|
5
|
+
suiteId: z.string().min(1),
|
|
6
|
+
version: z.string().min(1),
|
|
7
|
+
commitSha: z.string().regex(/^[a-f0-9]{7,64}$/),
|
|
8
|
+
success: z.boolean(),
|
|
9
|
+
resultRef: z.string().min(1),
|
|
10
|
+
}).strict();
|
|
11
|
+
export const stabilityEvidenceSchema = z.object({
|
|
12
|
+
schemaVersion: z.literal(1),
|
|
13
|
+
schemaId: z.literal(STABILITY_EVIDENCE_SCHEMA_ID),
|
|
14
|
+
suiteId: z.string().min(1),
|
|
15
|
+
version: z.string().min(1),
|
|
16
|
+
commitSha: z.string().regex(/^[a-f0-9]{7,64}$/),
|
|
17
|
+
runs: z.array(runRefSchema),
|
|
18
|
+
recordedRuns: z.number().int().min(0),
|
|
19
|
+
successfulRuns: z.number().int().min(0),
|
|
20
|
+
failedRuns: z.number().int().min(0),
|
|
21
|
+
ratio: z.number().min(0).max(1).nullable(),
|
|
22
|
+
minimumRuns: z.literal(5),
|
|
23
|
+
status: z.enum(["available", "unavailable"]),
|
|
24
|
+
reason: z.string().min(1).nullable(),
|
|
25
|
+
}).strict().superRefine((value, ctx) => {
|
|
26
|
+
if (value.recordedRuns !== value.runs.length)
|
|
27
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "recordedRuns must equal runs.length", path: ["recordedRuns"] });
|
|
28
|
+
if (value.successfulRuns !== value.runs.filter((run) => run.success).length)
|
|
29
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "successfulRuns must match run facts", path: ["successfulRuns"] });
|
|
30
|
+
if (value.failedRuns !== value.runs.filter((run) => !run.success).length)
|
|
31
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "failedRuns must match run facts", path: ["failedRuns"] });
|
|
32
|
+
for (const [index, run] of value.runs.entries()) {
|
|
33
|
+
if (run.suiteId !== value.suiteId || run.version !== value.version || run.commitSha !== value.commitSha) {
|
|
34
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "all runs must bind to the same suite/version/commit", path: ["runs", index] });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (value.recordedRuns >= value.minimumRuns && value.ratio === null)
|
|
38
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ratio required when minimum sample is met", path: ["ratio"] });
|
|
39
|
+
});
|
|
40
|
+
export function buildStabilityEvidence(input) {
|
|
41
|
+
const recordedRuns = input.runs.length;
|
|
42
|
+
const successfulRuns = input.runs.filter((run) => run.success).length;
|
|
43
|
+
const failedRuns = recordedRuns - successfulRuns;
|
|
44
|
+
const status = recordedRuns >= 5 ? "available" : "unavailable";
|
|
45
|
+
return stabilityEvidenceSchema.parse({
|
|
46
|
+
schemaVersion: 1,
|
|
47
|
+
schemaId: STABILITY_EVIDENCE_SCHEMA_ID,
|
|
48
|
+
...input,
|
|
49
|
+
recordedRuns,
|
|
50
|
+
successfulRuns,
|
|
51
|
+
failedRuns,
|
|
52
|
+
ratio: recordedRuns >= 5 ? successfulRuns / recordedRuns : null,
|
|
53
|
+
minimumRuns: 5,
|
|
54
|
+
status,
|
|
55
|
+
reason: recordedRuns >= 5 ? null : "minimum-sample-size-not-met",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -12,6 +12,7 @@ import { resolveAdapter } from "../../adapters/index.js";
|
|
|
12
12
|
import { loadHarnessManifest } from "../../governance/harness.js";
|
|
13
13
|
import { buildAuthoritySurfaceAuditNode, buildAuthoritySurfaceGateNode, resolveAuthoritySurfaceAudit, } from "./authority-surface.js";
|
|
14
14
|
import { applySddEmbeddedEnhancements, probeRepoLocalSddSkills, } from "./sdd-embedded.js";
|
|
15
|
+
import { discoverProjectGovernancePresence } from "./project-governance-context.js";
|
|
15
16
|
import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
|
|
16
17
|
import { materializeTaskReferenceDocs } from "../../task/source-references.js";
|
|
17
18
|
import { resolveVerifyPreset } from "../../executors/shell-verification.js";
|
|
@@ -1105,6 +1106,7 @@ export async function loadTaskHybridSources(repoRoot, taskId) {
|
|
|
1105
1106
|
executorModelMatrix: resolveExecutorModelMatrices(manifest),
|
|
1106
1107
|
verifyCommands,
|
|
1107
1108
|
sddEmbeddedSkills: await probeRepoLocalSddSkills(repoRoot),
|
|
1109
|
+
projectGovernancePresent: await discoverProjectGovernancePresence(repoRoot),
|
|
1108
1110
|
};
|
|
1109
1111
|
return sources;
|
|
1110
1112
|
}
|
|
@@ -2912,6 +2914,8 @@ function buildGenerateBackendPytestNode(sources) {
|
|
|
2912
2914
|
"- Validated Backend Test Analysis v1 under the current run contracts/ (analysis gate).",
|
|
2913
2915
|
"- Validated Backend Test Execution Contract v1 under contracts/backend-test-execution.json (execution gate).",
|
|
2914
2916
|
"Use only fixture names, env NAMES, testRoot, targetMode, and field/API facts already present in those contracts or reviewed cases. Do not invent production credentials or secret values.",
|
|
2917
|
+
"When targetMode is in-process (including demoted local npm/node managed servers): bootstrap the service inside function-scoped pytest fixtures under testcase/** — e.g. subprocess node server.js / startWelcomeServer with PORT=0 — and never require host-injected base URL env vars (clean-env shell will not provide WELCOME_BASE_URL / API_BASE_URL).",
|
|
2918
|
+
"Do not depend on requiredEnvNames being present at process start for in-process mode; if the contract still lists an env name, the fixture must set it or start the server without that env.",
|
|
2915
2919
|
"",
|
|
2916
2920
|
"## Output Steps (do in order):",
|
|
2917
2921
|
"1. First, output a brief summary: how many files, how many test functions planned",
|
|
@@ -3062,8 +3066,9 @@ function buildExecuteBackendPytestNode(sources, options = {}) {
|
|
|
3062
3066
|
const reportStem = options.reportStem ?? "backend-test";
|
|
3063
3067
|
const reportName = `${reportStem}-junit.xml`;
|
|
3064
3068
|
const exitName = `${reportStem}-pytest-exit.txt`;
|
|
3065
|
-
|
|
3066
|
-
|
|
3069
|
+
// Preflight snippet is already fail-closed (&&). Only the pytest body may use
|
|
3070
|
+
// ";" so STATUS capture still runs after non-zero pytest exits.
|
|
3071
|
+
const pytestBody = [
|
|
3067
3072
|
`REPORT="\${HARNESS_DAG_RUN_DIR}/reports/${reportName}"`,
|
|
3068
3073
|
`EXIT_FILE="\${HARNESS_DAG_RUN_DIR}/reports/${exitName}"`,
|
|
3069
3074
|
'mkdir -p "$(dirname "${REPORT}")"',
|
|
@@ -3075,6 +3080,7 @@ function buildExecuteBackendPytestNode(sources, options = {}) {
|
|
|
3075
3080
|
'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${REPORT}" ]; then exit 0; fi',
|
|
3076
3081
|
'exit "${STATUS}"',
|
|
3077
3082
|
].join("; ");
|
|
3083
|
+
const pytestCommand = `${preflightCommand} && { ${pytestBody}; }`;
|
|
3078
3084
|
return {
|
|
3079
3085
|
id: nodeId,
|
|
3080
3086
|
depends_on: options.dependsOn ?? [
|
|
@@ -3168,21 +3174,24 @@ function buildTestRetrospectNode(sources) {
|
|
|
3168
3174
|
"## Stats authority (deterministic only):",
|
|
3169
3175
|
"- Pass rate, failed/error/skipped counts, and failure list MUST come from contracts/backend-test-result.json only.",
|
|
3170
3176
|
"- AC coverage ratio / case counts MUST come from contracts/backend-test-case-manifest.json coverageSummary (or gate-derived fields). Do NOT invent coverage %.",
|
|
3177
|
+
"- Automation coverage MUST use coverageSummary.generatedCount / coverageSummary.caseCount. If either field is missing, write unavailable; do not estimate.",
|
|
3178
|
+
"- Code coverage MUST come only from the validated contracts/code-coverage-v1.json artifact generated by coverage.py/pytest-cov or JaCoCo. Show line, branch, function/method, covered, total, ratio, threshold, status, source scope, requirement IDs, tool, commit, and artifact hash.",
|
|
3179
|
+
"- Stability MUST come from independent Stability Evidence: use successfulRuns / recordedRuns, same suite/version, and require n≥5; a single run is unavailable.",
|
|
3171
3180
|
"- Use classify-backend-test-result-pi JSON as interpretive evidence only.",
|
|
3172
3181
|
"- NEVER rewrite a failed result as passed. Outcome gate (not this report) is authoritative for task success.",
|
|
3173
3182
|
"",
|
|
3174
3183
|
"## Report Structure:",
|
|
3175
3184
|
"1. Maturity Rating with rationale",
|
|
3176
|
-
"2. Test Coverage Summary (
|
|
3177
|
-
"3.
|
|
3178
|
-
"4.
|
|
3179
|
-
"5.
|
|
3185
|
+
"2. Test Coverage Summary (Result v1 pass rate, AC coverage, automation coverage, code coverage, and stability evidence)",
|
|
3186
|
+
"3. Failed Test Analysis (failure/error details, category, confidence, evidence, and owner direction)",
|
|
3187
|
+
"4. Defects (local Bug ledger in the same report directory; unavailable when absent)",
|
|
3188
|
+
"5. Risks (Critical/High/Medium/Low, impact, controls, residual risk, treatment; Critical risks block L-5, High risks do not automatically block)",
|
|
3189
|
+
"6. Regression Recommendations (immediate, related, periodic, deferred; every item links to failure/risk/AC/case IDs)",
|
|
3190
|
+
"7. L-5 conclusion with blocking items",
|
|
3180
3191
|
"",
|
|
3181
3192
|
"## Rating Criteria:",
|
|
3182
|
-
"-
|
|
3183
|
-
"-
|
|
3184
|
-
"- C: acCoverageRatio≥0.6 + ≥70% pass + no Critical findings",
|
|
3185
|
-
"- D: below C thresholds",
|
|
3193
|
+
"- L-5 ready requires pass rate=100%, AC coverage=100%, automation coverage≥90%, stability≥95% with n≥5, line coverage≥80%, branch coverage≥70%, skipped=0, and no blocking Critical risk.",
|
|
3194
|
+
"- Any required metric fail or unavailable means L-5 not-ready. Function/method coverage is displayed but not a gate. Preserve the existing A/B/C/D single-run rating separately.",
|
|
3186
3195
|
"",
|
|
3187
3196
|
"## Constraints:",
|
|
3188
3197
|
canWriteReport
|
|
@@ -3197,7 +3206,7 @@ function buildBackendTestOutcomeGateNode(sources) {
|
|
|
3197
3206
|
const gateCommand = buildBackendTestOutcomeGateShellSnippet();
|
|
3198
3207
|
return {
|
|
3199
3208
|
id: "backend-test-outcome-gate-shell",
|
|
3200
|
-
depends_on: ["
|
|
3209
|
+
depends_on: ["l5-metrics-pi"],
|
|
3201
3210
|
role: "verifier",
|
|
3202
3211
|
executor: "shell",
|
|
3203
3212
|
complexity: "LOW",
|
|
@@ -3220,6 +3229,30 @@ function buildBackendTestOutcomeGateNode(sources) {
|
|
|
3220
3229
|
},
|
|
3221
3230
|
};
|
|
3222
3231
|
}
|
|
3232
|
+
function buildL5MetricsNode(sources) {
|
|
3233
|
+
return {
|
|
3234
|
+
id: "l5-metrics-pi",
|
|
3235
|
+
depends_on: ["test-retrospect-pi"],
|
|
3236
|
+
role: "reviewer",
|
|
3237
|
+
executor: "pi",
|
|
3238
|
+
complexity: "MED",
|
|
3239
|
+
writePolicy: "read-only",
|
|
3240
|
+
allowedPaths: commonReadOnlyPaths(sources),
|
|
3241
|
+
forbiddenPaths: commonForbiddenPaths(sources),
|
|
3242
|
+
outputContract: "Exactly one JSON object with status=ready|not-ready, metrics, and blockingItems; no file writes.",
|
|
3243
|
+
subtask_prompt: [
|
|
3244
|
+
"You are the independent L-5 metrics node at the end of the existing backend-test DAG.",
|
|
3245
|
+
"The direct upstream test-retrospect-pi output is the primary report to assess. Read it together with the run-owned Result v1, Case Manifest v1, Code Coverage v1, and Stability Evidence artifacts when present.",
|
|
3246
|
+
"Do not create a new DAG, rewrite the retrospective report, change test outcome, or modify any repository file.",
|
|
3247
|
+
"Return exactly one JSON object and no surrounding prose.",
|
|
3248
|
+
"Required shape: {\"status\":\"ready\"|\"not-ready\",\"metrics\":{\"passRate\":metric,\"acCoverage\":metric,\"automationCoverage\":metric,\"stability\":metric,\"lineCoverage\":metric,\"branchCoverage\":metric,\"skipped\":metric,\"criticalRisks\":metric},\"blockingItems\":[string]}.",
|
|
3249
|
+
"Each metric must contain numerator, denominator, ratio, threshold, status=pass|fail|unavailable, and reason (null only when passed).",
|
|
3250
|
+
"Use only explicit evidence. Missing or invalid required evidence is unavailable, never zero or an estimate.",
|
|
3251
|
+
"L-5 ready requires pass rate=100%, AC coverage=100%, automation coverage>=90%, stability>=95% with n>=5, line coverage>=80%, branch coverage>=70%, skipped=0, and zero blocking Critical risks.",
|
|
3252
|
+
"Function/method coverage is display-only and does not gate L-5. Preserve the distinction between L-5 maturity and the Result v1 outcome gate.",
|
|
3253
|
+
].join("\n\n"),
|
|
3254
|
+
};
|
|
3255
|
+
}
|
|
3223
3256
|
const BACKEND_TEST_DEFAULTS = {
|
|
3224
3257
|
...HYBRID_DEFAULTS,
|
|
3225
3258
|
writePolicy: "read-only",
|
|
@@ -3237,7 +3270,7 @@ function buildBackendTestHybridDag(sources) {
|
|
|
3237
3270
|
const globalConstraints = [
|
|
3238
3271
|
...taskConfig.hardConstraints,
|
|
3239
3272
|
...STANDARD_GLOBAL_CONSTRAINTS,
|
|
3240
|
-
"backend-test-dag uses exactly
|
|
3273
|
+
"backend-test-dag uses exactly 16 real top-level tasks and executes pytest exactly once.",
|
|
3241
3274
|
"Case and semantic request-revision verdicts fail at deterministic gates; no in-run revision or repair writer is authorized.",
|
|
3242
3275
|
"Analysis, execution, manifest, semantic review, single-run result, classification, canonical result, retrospective and outcome evidence remain run-owned and fail-closed.",
|
|
3243
3276
|
"Functional test case IDs must use BE-<MODULE>-<NNN> format.",
|
|
@@ -3301,8 +3334,10 @@ function buildBackendTestHybridDag(sources) {
|
|
|
3301
3334
|
const retrospect = buildTestRetrospectNode(sources);
|
|
3302
3335
|
retrospect.depends_on = [context.id];
|
|
3303
3336
|
retrospect.subtask_prompt = retrospect.subtask_prompt.replaceAll("select-effective-backend-test-result-shell", context.id);
|
|
3337
|
+
const l5Metrics = buildL5MetricsNode(sources);
|
|
3338
|
+
l5Metrics.depends_on = [retrospect.id];
|
|
3304
3339
|
const outcome = buildBackendTestOutcomeGateNode(sources);
|
|
3305
|
-
const tasks = [analyze, contracts, generateCases, manifest, reviewCases, caseGate, generatePytest, semanticReview, semanticMaterialize, semanticGate, execute, classify, context, retrospect, outcome];
|
|
3340
|
+
const tasks = [analyze, contracts, generateCases, manifest, reviewCases, caseGate, generatePytest, semanticReview, semanticMaterialize, semanticGate, execute, classify, context, retrospect, l5Metrics, outcome];
|
|
3306
3341
|
const spec = { version: 3, title: `Backend test DAG: ${taskConfig.title}`, runtimeContract: GENERATED_DAG_RUNTIME_CONTRACT, outputLanguage: sources.outputLanguage ?? DEFAULT_DAG_OUTPUT_LANGUAGE, objective: extractObjective(sources.requirementMarkdown, taskConfig.title), successCriteria: extractSuccessCriteria(sources.requirementMarkdown, sources.taskId), globalConstraints, defaults: { ...BACKEND_TEST_DEFAULTS, contextProfile: taskConfig.contextProfile }, skillsByRole: BACKEND_TEST_SKILLS_BY_ROLE, executorModels: sources.executorModelMatrix ?? DEFAULT_DAG_EXECUTOR_MODELS, tasks };
|
|
3307
3342
|
applyDefaultReadOnlyRetryPolicy(spec);
|
|
3308
3343
|
parseDagSpec(spec);
|
|
@@ -4630,11 +4665,35 @@ function buildHybridDagForTemplate(sources, template) {
|
|
|
4630
4665
|
else
|
|
4631
4666
|
spec = buildSupervisedHybridDag(standard, sources);
|
|
4632
4667
|
}
|
|
4668
|
+
applyProjectGovernanceReview(spec, template, sources);
|
|
4633
4669
|
spec.sourceBinding = buildDagSourceBinding(sources);
|
|
4670
|
+
assertNoGovernanceFlagOnDisallowedTemplate(spec, template);
|
|
4634
4671
|
parseDagSpec(spec);
|
|
4635
4672
|
assertValidDagSpec(spec);
|
|
4636
4673
|
return spec;
|
|
4637
4674
|
}
|
|
4675
|
+
const GOVERNANCE_DISALLOWED_TEMPLATES = new Set([
|
|
4676
|
+
"frontend-implementation",
|
|
4677
|
+
"frontend-test-dag",
|
|
4678
|
+
"backend-test-dag",
|
|
4679
|
+
"knowledge-sync-dag",
|
|
4680
|
+
"knowledge-graph-bootstrap-dag",
|
|
4681
|
+
]);
|
|
4682
|
+
/**
|
|
4683
|
+
* Generate-time guard (constraint 2 / AC-007): the governance standard review
|
|
4684
|
+
* flag must never appear on knowledge-sync, knowledge-graph-bootstrap,
|
|
4685
|
+
* backend-test, or frontend tasks. Only standard verify-pi and the shared
|
|
4686
|
+
* review-pi (reviewed/supervised) opt in explicitly.
|
|
4687
|
+
*/
|
|
4688
|
+
function assertNoGovernanceFlagOnDisallowedTemplate(spec, template) {
|
|
4689
|
+
if (!GOVERNANCE_DISALLOWED_TEMPLATES.has(template))
|
|
4690
|
+
return;
|
|
4691
|
+
for (const task of spec.tasks) {
|
|
4692
|
+
if (task.governanceStandardReview) {
|
|
4693
|
+
throw new Error(`governanceStandardReview must not be set on ${template} task ${task.id}`);
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4638
4697
|
export function buildHybridDagFromTask(sources, options = {}) {
|
|
4639
4698
|
const selection = resolveTaskDagTemplateSelection({
|
|
4640
4699
|
taskKind: sources.taskConfig.taskKind,
|
|
@@ -4758,6 +4817,84 @@ function buildReviewGateNode(sources) {
|
|
|
4758
4817
|
},
|
|
4759
4818
|
};
|
|
4760
4819
|
}
|
|
4820
|
+
function enableProjectGovernanceOnNode(task) {
|
|
4821
|
+
task.governanceStandardReview = true;
|
|
4822
|
+
}
|
|
4823
|
+
/**
|
|
4824
|
+
* Apply governance only to general implementation DAGs, and only when the
|
|
4825
|
+
* target repository actually contains AGENTS.md. Standard reuses verify-pi;
|
|
4826
|
+
* reviewed/supervised reuse review-pi and their existing review verdict gate.
|
|
4827
|
+
*/
|
|
4828
|
+
function applyProjectGovernanceReview(spec, template, sources) {
|
|
4829
|
+
if (!sources.projectGovernancePresent)
|
|
4830
|
+
return;
|
|
4831
|
+
if (template === "standard-dag") {
|
|
4832
|
+
const verify = spec.tasks.find((task) => task.id === "verify-pi");
|
|
4833
|
+
if (!verify)
|
|
4834
|
+
return;
|
|
4835
|
+
enableProjectGovernanceOnNode(verify);
|
|
4836
|
+
insertGovernanceStandardGate(spec, sources);
|
|
4837
|
+
return;
|
|
4838
|
+
}
|
|
4839
|
+
if (template === "review-gated-dag" || template === "supervised-implementation") {
|
|
4840
|
+
const review = spec.tasks.find((task) => task.id === "review-pi");
|
|
4841
|
+
if (review)
|
|
4842
|
+
enableProjectGovernanceOnNode(review);
|
|
4843
|
+
}
|
|
4844
|
+
}
|
|
4845
|
+
/**
|
|
4846
|
+
* Deterministic governance standard gate for standard-dag closeout. It reuses
|
|
4847
|
+
* the standard `verdictGate` preset (fromNodeId=verify-pi, accept
|
|
4848
|
+
* ["VERDICT: pass"], first-verdict-line). When no applicable AGENTS.md/standard
|
|
4849
|
+
* exists, projectGovernanceGate returns success without parsing a verdict.
|
|
4850
|
+
* When an applicable mandatory standard is violated, verify-pi must emit
|
|
4851
|
+
* VERDICT: request-revision and the gate blocks closeout. No new model node.
|
|
4852
|
+
*/
|
|
4853
|
+
function buildGovernanceStandardGateNode(sources) {
|
|
4854
|
+
return {
|
|
4855
|
+
id: "governance-standard-gate-shell",
|
|
4856
|
+
depends_on: ["verify-pi"],
|
|
4857
|
+
role: "verifier",
|
|
4858
|
+
executor: "shell",
|
|
4859
|
+
complexity: "LOW",
|
|
4860
|
+
writePolicy: "read-only",
|
|
4861
|
+
allowedPaths: commonReadOnlyPaths(sources),
|
|
4862
|
+
forbiddenPaths: commonForbiddenPaths(sources),
|
|
4863
|
+
outputContract: "Deterministic governance verdict gate: exit 0 only when verify-pi first VERDICT line is pass (covers applicable mandatory standard compliance). No file writes.",
|
|
4864
|
+
subtask_prompt: "Deterministic gate: block closeout unless verify-pi emitted VERDICT: pass, which requires no unresolved applicable mandatory governance-standard violation. Read-only: do not modify files.",
|
|
4865
|
+
shell: {
|
|
4866
|
+
commands: [],
|
|
4867
|
+
projectGovernanceGate: {
|
|
4868
|
+
contextPath: ".runtime/project-governance-context.json",
|
|
4869
|
+
},
|
|
4870
|
+
verdictGate: {
|
|
4871
|
+
fromNodeId: "verify-pi",
|
|
4872
|
+
accept: ["VERDICT: pass"],
|
|
4873
|
+
label: "governance-standard",
|
|
4874
|
+
lineMode: "first-verdict-line",
|
|
4875
|
+
},
|
|
4876
|
+
cwd: ".",
|
|
4877
|
+
timeoutMs: 60000,
|
|
4878
|
+
},
|
|
4879
|
+
};
|
|
4880
|
+
}
|
|
4881
|
+
/**
|
|
4882
|
+
* Insert the governance-standard gate between verify-pi and closeout-pi in a
|
|
4883
|
+
* standard DAG. Idempotent and no-op if verify-pi/closeout-pi are absent.
|
|
4884
|
+
*/
|
|
4885
|
+
function insertGovernanceStandardGate(spec, sources) {
|
|
4886
|
+
const closeout = spec.tasks.find((task) => task.id === "closeout-pi");
|
|
4887
|
+
const verify = spec.tasks.find((task) => task.id === "verify-pi");
|
|
4888
|
+
if (!closeout || !verify)
|
|
4889
|
+
return;
|
|
4890
|
+
if (spec.tasks.some((task) => task.id === "governance-standard-gate-shell")) {
|
|
4891
|
+
return;
|
|
4892
|
+
}
|
|
4893
|
+
const gate = buildGovernanceStandardGateNode(sources);
|
|
4894
|
+
const closeoutIndex = spec.tasks.findIndex((task) => task.id === "closeout-pi");
|
|
4895
|
+
spec.tasks.splice(closeoutIndex, 0, gate);
|
|
4896
|
+
closeout.depends_on = ["governance-standard-gate-shell"];
|
|
4897
|
+
}
|
|
4761
4898
|
function buildReviewGatedHybridDag(standard, sources) {
|
|
4762
4899
|
const spec = {
|
|
4763
4900
|
...standard,
|