@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/cli/command-definitions.js +13 -0
  3. package/dist/cli/program.js +4 -0
  4. package/dist/commands/coverage-report.js +50 -0
  5. package/dist/executors/dag-pi-executor.js +63 -9
  6. package/dist/executors/shell-executor.js +30 -0
  7. package/dist/executors/shell-write-guard.js +64 -2
  8. package/dist/worker/delivery/git-transaction.js +43 -8
  9. package/dist/worker/observe/paths.js +81 -0
  10. package/dist/worker/observe/routes.js +127 -19
  11. package/dist/worker/observe/spec-evidence.js +84 -0
  12. package/dist/worker/observe/static/api.js +23 -0
  13. package/dist/worker/observe/static/state.js +26 -0
  14. package/dist/worker/observe/static/styles.css +10 -0
  15. package/dist/worker/observe/static/views/dag-inspector.js +173 -6
  16. package/dist/workflows/dag/backend-test-coverage-contract.js +202 -0
  17. package/dist/workflows/dag/backend-test-execution-contract.js +84 -18
  18. package/dist/workflows/dag/backend-test-stability-contract.js +57 -0
  19. package/dist/workflows/dag/init-hybrid.js +150 -13
  20. package/dist/workflows/dag/l5-report-metrics.js +36 -0
  21. package/dist/workflows/dag/node-execution.js +32 -5
  22. package/dist/workflows/dag/project-governance-context.js +508 -0
  23. package/dist/workflows/dag/prompt.js +46 -1
  24. package/dist/workflows/dag/skill-snapshot.js +1 -0
  25. package/dist/workflows/dag/types.js +10 -0
  26. package/dist/workflows/dag/validate.js +28 -0
  27. package/docs/architecture/evolution.md +3 -1
  28. package/docs/templates/agent-dag.schema.json +15 -0
  29. package/docs/templates/agent-dag.supervised-implementation.json +1 -0
  30. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +2 -0
  31. package/docs/templates/backend-test-dag.json +39 -6
  32. package/docs/templates/backend-test-dag.retrospect.prompt.md +36 -7
  33. package/package.json +1 -1
  34. 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
- const managedStart = asRecord(next.managedCommand) &&
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
- const pytestCommand = [
3066
- preflightCommand,
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 (manifest coverageSummary + Result v1 pass rate)",
3177
- "3. Review Findings and resolution status",
3178
- "4. Failed Test Analysis (if any) + classification category",
3179
- "5. Recommendations for improvement",
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
- "- A: coverageSummary.acCoverageRatio=1 + 100% pytest pass + no Critical findings",
3183
- "- B: acCoverageRatio≥0.8 + ≥90% pass + Low findings only",
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: ["test-retrospect-pi"],
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 15 real top-level tasks and executes pytest exactly once.",
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,