@tea-agent/loop-agent 0.16.19 → 0.16.20

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 (30) hide show
  1. package/CHANGELOG.md +22 -5
  2. package/README.md +8 -0
  3. package/dist/cli/command-definitions.js +4 -2
  4. package/dist/cli/program.js +2 -1
  5. package/dist/cli/update/init-surface-notifier.js +167 -0
  6. package/dist/cli/update/policy.js +36 -1
  7. package/dist/cli/update/runtime-activity.js +29 -0
  8. package/dist/cli.js +14 -2
  9. package/dist/commands/init.js +85 -2
  10. package/dist/executors/shell-executor.js +92 -66
  11. package/dist/executors/shell-write-guard.js +5 -0
  12. package/dist/shared/runtime-activity.js +6 -0
  13. package/dist/worker/observability/read-model.js +10 -7
  14. package/dist/worker/observe/static/views/session-timeline.js +1 -1
  15. package/dist/workflows/dag/backend-test-case-manifest.js +13 -3
  16. package/dist/workflows/dag/backend-test-classification-contract.js +38 -0
  17. package/dist/workflows/dag/backend-test-contract-envelope.js +167 -0
  18. package/dist/workflows/dag/backend-test-semantic-review-contract.js +2 -2
  19. package/dist/workflows/dag/init-hybrid.js +47 -408
  20. package/dist/workflows/dag/node-execution.js +4 -3
  21. package/dist/workflows/dag/types.js +1 -5
  22. package/docs/README.md +1 -0
  23. package/docs/templates/agent-dag.schema.json +1 -1
  24. package/docs/templates/backend-test-case-manifest.schema.json +35 -2
  25. package/docs/templates/backend-test-dag.json +39 -340
  26. package/docs/templates/backend-test-dag.review-cases.prompt.md +4 -4
  27. package/package.json +1 -1
  28. package/skills/loop-agent/references/command-reference.md +4 -0
  29. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  30. package/dist/workflows/dag/backend-test-repair-contract.js +0 -94
@@ -6,6 +6,7 @@ import { writeDagNodeTextArtifact } from "../infrastructure/harness/artifact-sto
6
6
  import { truncateOutput } from "../shared/output-truncation.js";
7
7
  import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdictGateShellCommand, } from "./shell-presets.js";
8
8
  import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
9
+ import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
9
10
  import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
10
11
  import { materializeFrontendTestResult, } from "../workflows/dag/frontend-test-result-contract.js";
11
12
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
@@ -14,8 +15,8 @@ import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout,
14
15
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
15
16
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
16
17
  import { materializeBackendTestResultFromRunDir } from "../workflows/dag/backend-test-result-contract.js";
17
- import { buildBackendTestEffectiveResultSelectorShellSnippet, buildBackendTestRepairEligibilityShellSnippet, buildBackendTestRepairSafetyShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-repair-contract.js";
18
- import { materializeBackendTestSemanticReview } from "../workflows/dag/backend-test-semantic-review-contract.js";
18
+ import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
19
+ import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
19
20
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
20
21
  import { buildShellProcessEnv } from "./shell-verification.js";
21
22
  const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
@@ -287,10 +288,7 @@ async function executeBackendTestPipeline(input, meta) {
287
288
  const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
288
289
  const wrapper = JSON.parse(await readFile(wrapperPath, "utf8"));
289
290
  const raw = wrapper.assistantText?.trim() || wrapper.stdout?.trim() || "";
290
- const fenced = raw.match(/^```json\s*([\s\S]*?)\s*```$/i);
291
- const envelope = JSON.parse(fenced ? fenced[1] : raw);
292
- if (!envelope.analysis || !envelope.execution)
293
- throw new Error("backend-test contract envelope requires analysis and execution");
291
+ const envelope = extractBackendTestContractEnvelope(raw);
294
292
  await writeFile(path.join(meta.runDir, "backend-test-analysis-envelope.json"), JSON.stringify({ assistantText: JSON.stringify(envelope.analysis) }));
295
293
  await writeFile(path.join(meta.runDir, "backend-test-execution-envelope.json"), JSON.stringify({ assistantText: JSON.stringify(envelope.execution) }));
296
294
  const analysis = await materializeBackendTestAnalysisContract({
@@ -308,17 +306,84 @@ async function executeBackendTestPipeline(input, meta) {
308
306
  });
309
307
  outputs.push(`analysis=${analysis.path}`, `execution=${execution.path}`);
310
308
  }
311
- else if (pipeline === "semantic-initial" || pipeline === "semantic-final") {
312
- const fromNodeId = pipeline === "semantic-final"
313
- ? "review-generated-backend-pytest-final-pi"
314
- : "review-generated-backend-pytest-pi";
315
- const artifactName = pipeline === "semantic-final"
316
- ? "backend-test-semantic-review-final.json"
317
- : "backend-test-semantic-review.json";
309
+ else if (pipeline === "semantic-initial") {
310
+ // Same pipeline id for node 09 (materialize+trace) and node 10 (pass-only authorize).
311
+ // Branch only on task.id so request-revision remains materializable at 09 and fail-closed at 10.
312
+ if (input.task.id === "backend-test-semantic-gate-shell") {
313
+ const artifactPath = path.join(meta.runDir, "contracts", "backend-test-semantic-review.json");
314
+ let raw;
315
+ try {
316
+ raw = await readFile(artifactPath, "utf8");
317
+ }
318
+ catch {
319
+ return {
320
+ ok: false,
321
+ stdout: "",
322
+ stderr: "backend-test semantic authorize: missing canonical artifact contracts/backend-test-semantic-review.json",
323
+ failureCategory: "invalid-output",
324
+ durationMs: Date.now() - started,
325
+ };
326
+ }
327
+ let parsed;
328
+ try {
329
+ parsed = JSON.parse(raw);
330
+ }
331
+ catch {
332
+ return {
333
+ ok: false,
334
+ stdout: "",
335
+ stderr: "backend-test semantic authorize: malformed canonical artifact contracts/backend-test-semantic-review.json",
336
+ failureCategory: "invalid-output",
337
+ durationMs: Date.now() - started,
338
+ };
339
+ }
340
+ if (!parsed ||
341
+ typeof parsed !== "object" ||
342
+ Array.isArray(parsed) ||
343
+ !["verdict", "findings", "summary"].every((key) => Object.prototype.hasOwnProperty.call(parsed, key))) {
344
+ return {
345
+ ok: false,
346
+ stdout: "",
347
+ stderr: "backend-test semantic authorize: malformed canonical artifact (missing required canonical fields)",
348
+ failureCategory: "invalid-output",
349
+ durationMs: Date.now() - started,
350
+ };
351
+ }
352
+ const validated = backendTestSemanticReviewSchema.safeParse(parsed);
353
+ if (!validated.success) {
354
+ const detail = validated.error.issues
355
+ .map((issue) => `${issue.path.join(".") || "artifact"}: ${issue.message}`)
356
+ .join("; ");
357
+ return {
358
+ ok: false,
359
+ stdout: "",
360
+ stderr: `backend-test semantic authorize: malformed canonical artifact (strict schema validation failed: ${detail})`,
361
+ failureCategory: "invalid-output",
362
+ durationMs: Date.now() - started,
363
+ };
364
+ }
365
+ const review = validated.data;
366
+ if (review.verdict === "pass") {
367
+ return {
368
+ ok: true,
369
+ stdout: JSON.stringify(review),
370
+ stderr: "",
371
+ failureCategory: "success",
372
+ durationMs: Date.now() - started,
373
+ };
374
+ }
375
+ return {
376
+ ok: false,
377
+ stdout: JSON.stringify(review),
378
+ stderr: "backend-test semantic authorize blocked: verdict=request-revision",
379
+ failureCategory: "invalid-output",
380
+ durationMs: Date.now() - started,
381
+ };
382
+ }
318
383
  const review = await materializeBackendTestSemanticReview({
319
384
  runDir: meta.runDir,
320
- fromNodeId,
321
- artifactName,
385
+ fromNodeId: "review-generated-backend-pytest-pi",
386
+ artifactName: "backend-test-semantic-review.json",
322
387
  outputDir: "contracts",
323
388
  });
324
389
  const trace = await runBackendTestTraceabilityGate({
@@ -327,27 +392,7 @@ async function executeBackendTestPipeline(input, meta) {
327
392
  });
328
393
  outputs.push(`semantic=${review.path}`, formatTraceabilityGateStdout(trace));
329
394
  const parsed = JSON.parse(await readFile(review.path, "utf8"));
330
- if (pipeline === "semantic-initial") {
331
- return { ok: true, stdout: JSON.stringify(parsed), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
332
- }
333
- if (pipeline === "semantic-final") {
334
- if (parsed.verdict !== "pass")
335
- throw new Error("backend pytest semantic review did not pass");
336
- }
337
- }
338
- else if (pipeline === "semantic-effective") {
339
- // Prefer final review contract when revision ran; otherwise accept initial pass.
340
- const finalPath = path.join(meta.runDir, "contracts", "backend-test-semantic-review-final.json");
341
- const initialPath = path.join(meta.runDir, "contracts", "backend-test-semantic-review.json");
342
- const chosen = existsSync(finalPath) ? finalPath : initialPath;
343
- if (!existsSync(chosen)) {
344
- throw new Error("missing backend-test semantic review contract for effective gate");
345
- }
346
- const parsed = JSON.parse(await readFile(chosen, "utf8"));
347
- if (parsed.verdict !== "pass") {
348
- throw new Error("backend pytest semantic review did not pass");
349
- }
350
- outputs.push(`semantic=${chosen}`, `verdict=${parsed.verdict}`);
395
+ return { ok: true, stdout: JSON.stringify(parsed), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
351
396
  }
352
397
  else if (pipeline === "execute-parse-initial") {
353
398
  const results = await executePipelineCommands(input, meta);
@@ -364,43 +409,24 @@ async function executeBackendTestPipeline(input, meta) {
364
409
  });
365
410
  outputs.push(...results.map((result) => result.stdout), `result=${artifact.path}`);
366
411
  }
367
- else if (pipeline === "classification-eligibility") {
412
+ else if (pipeline === "classification-result-context") {
368
413
  const classification = await materializeBackendTestClassification({
369
414
  runDir: meta.runDir,
370
415
  fromNodeId: "classify-backend-test-result-pi",
371
416
  artifactName: "backend-test-classification.json",
372
417
  outputDir: "contracts",
373
418
  });
374
- const results = await executePipelineCommands(input, meta, [buildBackendTestRepairEligibilityShellSnippet()]);
375
- if (!results.every((result) => result.ok))
376
- throw new Error(results.find((result) => !result.ok)?.stderr || "repair eligibility failed");
377
- const eligibility = JSON.parse(await readFile(path.join(meta.runDir, "contracts", "backend-test-repair-eligibility.json"), "utf8"));
378
- return { ok: true, stdout: JSON.stringify(eligibility), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
379
- }
380
- else if (pipeline === "repair-safety-traceability") {
381
- const results = await executePipelineCommands(input, meta, [buildBackendTestRepairSafetyShellSnippet()]);
382
- if (!results.every((result) => result.ok))
383
- throw new Error(results.find((result) => !result.ok)?.stderr || "repair safety failed");
384
- const trace = await runBackendTestTraceabilityGate({ runDir: meta.runDir, workspaceRoot: input.cwd });
385
- outputs.push(...results.map((result) => result.stdout), formatTraceabilityGateStdout(trace));
386
- }
387
- else if (pipeline === "finalize-effective-result") {
388
- const eligibilityPath = path.join(meta.runDir, "contracts", "backend-test-repair-eligibility.json");
389
- const eligibility = existsSync(eligibilityPath)
390
- ? JSON.parse(await readFile(eligibilityPath, "utf8"))
391
- : { eligible: false };
392
- const finalNeeded = eligibility.eligible === true;
393
- if (finalNeeded) {
394
- const results = await executePipelineCommands(input, meta);
395
- if (!results.every((result) => result.ok))
396
- return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: results.find((result) => !result.ok)?.stderr ?? "final pytest failed", failureCategory: results.find((result) => !result.ok)?.failureCategory ?? "nonzero-exit", durationMs: Date.now() - started };
397
- const artifact = await materializeBackendTestResultFromRunDir({ runDir: meta.runDir, fromNodeId: input.task.id, artifactName: "backend-test-result-final.json", outputDir: "contracts", junitRelativePath: "reports/backend-test-final-junit.xml" });
398
- outputs.push(...results.map((result) => result.stdout), `final=${artifact.path}`);
399
- }
400
- const selected = await executePipelineCommands(input, meta, [buildBackendTestEffectiveResultSelectorShellSnippet()]);
419
+ const selected = await executePipelineCommands(input, meta, [buildBackendTestCanonicalResultFromInitialShellSnippet()]);
401
420
  if (!selected.every((result) => result.ok))
402
- throw new Error(selected.find((result) => !result.ok)?.stderr || "effective result selection failed");
403
- outputs.push(...selected.map((result) => result.stdout));
421
+ throw new Error(selected.find((result) => !result.ok)?.stderr || "canonical result materialization failed");
422
+ const contracts = path.join(meta.runDir, "contracts");
423
+ const context = {
424
+ schemaVersion: 1,
425
+ result: JSON.parse(await readFile(path.join(contracts, "backend-test-result.json"), "utf8")),
426
+ manifest: JSON.parse(await readFile(path.join(contracts, "backend-test-case-manifest.json"), "utf8")),
427
+ classification: JSON.parse(await readFile(path.join(contracts, "backend-test-classification.json"), "utf8")),
428
+ };
429
+ outputs.push(`classification=${classification.path}`, ...selected.map((result) => result.stdout), JSON.stringify(context, null, 2));
404
430
  }
405
431
  else {
406
432
  throw new Error(`unsupported backend-test pipeline: ${pipeline}`);
@@ -48,6 +48,11 @@ export function isEphemeralToolCachePath(filePath) {
48
48
  if (normalized === ".ruff_cache" || normalized.startsWith(".ruff_cache/")) {
49
49
  return true;
50
50
  }
51
+ // playwright-cli default session dumps (console/page/network) under repo cwd.
52
+ // Real browser case evidence must still be written under testcase/** explicitly.
53
+ if (normalized === ".playwright-cli" || normalized.startsWith(".playwright-cli/")) {
54
+ return true;
55
+ }
51
56
  if (normalized === ".coverage" || normalized.startsWith(".coverage.")) {
52
57
  return true;
53
58
  }
@@ -0,0 +1,6 @@
1
+ export function isInitRuntimeActive(activity) {
2
+ return (activity.activeDagRunIds.length > 0 ||
3
+ activity.activeWorkerBatches > 0 ||
4
+ activity.activeWorkerTasks > 0 ||
5
+ activity.workerProjectionError !== undefined);
6
+ }
@@ -1502,16 +1502,19 @@ async function loadBackendTestProjection(runDir, nodes) {
1502
1502
  else if (eligible)
1503
1503
  repairStatus = "eligible";
1504
1504
  const effectiveSource = final ? "final" : "initial";
1505
+ const legacy = Boolean(eligibility || final || repairNode || safetyNode);
1505
1506
  return {
1506
1507
  ...(initial ? { initial: result(initial) } : {}),
1507
1508
  ...(classification ? { classification: { category: readString(classification, "category"), confidence: readNumber(classification, "confidence") } } : {}),
1508
- repair: {
1509
- eligible,
1510
- reason: eligibility ? readString(eligibility, "reason") : undefined,
1511
- attempt: final || repairAttempted ? 1 : 0,
1512
- status: repairStatus,
1513
- },
1514
- ...(final ? { final: result(final) } : {}),
1509
+ ...(legacy ? {
1510
+ repair: {
1511
+ eligible,
1512
+ reason: eligibility ? readString(eligibility, "reason") : undefined,
1513
+ attempt: final || repairAttempted ? 1 : 0,
1514
+ status: repairStatus,
1515
+ },
1516
+ ...(final ? { final: result(final) } : {}),
1517
+ } : {}),
1515
1518
  ...(effective ? { effective: { source: effectiveSource, outcome: readString(effective, "outcome") } } : {}),
1516
1519
  ...(manifest && manifest.coverageSummary ? { coverage: manifest.coverageSummary } : {}),
1517
1520
  };
@@ -209,7 +209,7 @@ export function sessionEventTimestamp(event) {
209
209
  export function formatSessionEventTime(event) {
210
210
  const ts = sessionEventTimestamp(event);
211
211
  if (!ts) return "未记录时间";
212
- return formatTs(ts).replace(/\s.*/, "") || formatTs(ts);
212
+ return formatTs(ts);
213
213
  }
214
214
 
215
215
  export function sessionEventKind(event) {
@@ -335,7 +335,18 @@ export async function materializeBackendTestCaseManifest(input) {
335
335
  if (secrets.length) {
336
336
  throw new Error(`invalid-output: ${secrets.join("; ")}`);
337
337
  }
338
- const result = backendTestCaseManifestSchema.safeParse(parsed);
338
+ // Model-provided top-level coverageSummary is non-authoritative. Secret scan
339
+ // above still inspects it; strip before strict schema parse so partial/
340
+ // custom shapes cannot fail materialization, then always recompute.
341
+ let candidate = parsed;
342
+ if (candidate &&
343
+ typeof candidate === "object" &&
344
+ !Array.isArray(candidate) &&
345
+ Object.prototype.hasOwnProperty.call(candidate, "coverageSummary")) {
346
+ const { coverageSummary: _ignored, ...rest } = candidate;
347
+ candidate = rest;
348
+ }
349
+ const result = backendTestCaseManifestSchema.safeParse(candidate);
339
350
  if (!result.success) {
340
351
  throw new Error(`invalid-output: ${result.error.issues
341
352
  .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
@@ -343,8 +354,7 @@ export async function materializeBackendTestCaseManifest(input) {
343
354
  }
344
355
  const withSummary = {
345
356
  ...result.data,
346
- coverageSummary: result.data.coverageSummary ??
347
- computeCaseManifestCoverageSummary(result.data),
357
+ coverageSummary: computeCaseManifestCoverageSummary(result.data),
348
358
  };
349
359
  assertBackendTestCaseManifestInvariants(withSummary, input.sourceBinding);
350
360
  const relativePath = path.posix.join(input.outputDir, input.artifactName);
@@ -0,0 +1,38 @@
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
+ import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
+ import { BACKEND_TEST_CLASSIFICATION_CATEGORIES, } from "./backend-test-result-contract.js";
7
+ import { extractUniqueJsonObject } from "./backend-test-contract-envelope.js";
8
+ export const BACKEND_TEST_CLASSIFICATION_SCHEMA_ID = "backend-test-classification-v1";
9
+ export const backendTestClassificationContractSchema = z
10
+ .object({
11
+ category: z.enum(BACKEND_TEST_CLASSIFICATION_CATEGORIES),
12
+ evidence: z.array(z.string().min(1)).min(1),
13
+ confidence: z.number().min(0).max(1),
14
+ notes: z.string().min(1),
15
+ })
16
+ .strict();
17
+ export async function materializeBackendTestClassification(input) {
18
+ const wrapperPath = path.join(input.runDir, `${input.fromNodeId}.json`);
19
+ const wrapper = JSON.parse(await readFile(wrapperPath, "utf8"));
20
+ const raw = wrapper.assistantText?.trim() || wrapper.stdout?.trim() || "";
21
+ if (!raw)
22
+ throw new Error("backend-test classification output is empty");
23
+ const classification = backendTestClassificationContractSchema.parse(extractUniqueJsonObject(raw));
24
+ const relativePath = path.posix.join(input.outputDir, input.artifactName);
25
+ const artifactPath = await writeDagRunJsonArtifact(input.runDir, relativePath, classification);
26
+ const serialized = `${JSON.stringify(classification, null, 2)}\n`;
27
+ return {
28
+ path: artifactPath,
29
+ sha256: createHash("sha256").update(serialized).digest("hex"),
30
+ schemaId: BACKEND_TEST_CLASSIFICATION_SCHEMA_ID,
31
+ };
32
+ }
33
+ export function buildBackendTestCanonicalResultFromInitialShellSnippet() {
34
+ return [
35
+ 'test -n "${HARNESS_DAG_RUN_DIR:-}" || { echo "missing HARNESS_DAG_RUN_DIR for backend-test canonical result" >&2; exit 2; }',
36
+ `node -e 'const fs=require("fs"),path=require("path"),crypto=require("crypto");const root=process.env.HARNESS_DAG_RUN_DIR;const contracts=path.join(root,"contracts");const source=path.join(contracts,"backend-test-result-initial.json");if(!fs.existsSync(source))throw new Error("missing initial backend-test result");const result=JSON.parse(fs.readFileSync(source,"utf8"));const target=path.join(contracts,"backend-test-result.json");const body=JSON.stringify(result,null,2)+"\\n";fs.writeFileSync(target,body);process.stdout.write(JSON.stringify({canonicalResult:"backend-test-result-initial.json",outcome:result.outcome,sha256:crypto.createHash("sha256").update(body).digest("hex")}));'`,
37
+ ].join("; ");
38
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Deterministic, fail-closed unique JSON object extraction for backend-test pipelines.
3
+ *
4
+ * Accepts pure JSON object, unique fenced json, prose+unique fence, or prose+unique
5
+ * bracket-balanced bare object. Contracts envelope adds analysis/execution validation.
6
+ * Semantic review / classification materializers reuse the same scanner.
7
+ */
8
+ const JSON_FENCE_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/gi;
9
+ function isPlainObject(value) {
10
+ return value !== null && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+ /**
13
+ * Collect balanced top-level `{...}` object spans with string/escape awareness.
14
+ * Does not use greedy regex to guess JSON.
15
+ */
16
+ export function findBalancedObjectSpans(text) {
17
+ return scanBalancedObjectSpans(text).spans;
18
+ }
19
+ function scanBalancedObjectSpans(text) {
20
+ const spans = [];
21
+ let depth = 0;
22
+ let start = -1;
23
+ let inString = false;
24
+ let escape = false;
25
+ for (let i = 0; i < text.length; i += 1) {
26
+ const ch = text[i];
27
+ if (inString) {
28
+ if (escape) {
29
+ escape = false;
30
+ continue;
31
+ }
32
+ if (ch === "\\") {
33
+ escape = true;
34
+ continue;
35
+ }
36
+ if (ch === '"') {
37
+ inString = false;
38
+ }
39
+ continue;
40
+ }
41
+ if (ch === '"') {
42
+ inString = true;
43
+ continue;
44
+ }
45
+ if (ch === "{") {
46
+ if (depth === 0)
47
+ start = i;
48
+ depth += 1;
49
+ continue;
50
+ }
51
+ if (ch === "}") {
52
+ if (depth === 0)
53
+ continue;
54
+ depth -= 1;
55
+ if (depth === 0 && start >= 0) {
56
+ spans.push({ start, end: i + 1 });
57
+ start = -1;
58
+ }
59
+ }
60
+ }
61
+ return { spans, unclosedObject: depth !== 0 };
62
+ }
63
+ function collectFencedJsonBlocks(text) {
64
+ const blocks = [];
65
+ JSON_FENCE_RE.lastIndex = 0;
66
+ for (const match of text.matchAll(JSON_FENCE_RE)) {
67
+ const start = match.index;
68
+ if (start === undefined)
69
+ continue;
70
+ blocks.push({
71
+ body: match[1] ?? "",
72
+ start,
73
+ end: start + match[0].length,
74
+ });
75
+ }
76
+ return blocks;
77
+ }
78
+ function parseObjectJson(candidate, label) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(candidate);
82
+ }
83
+ catch (error) {
84
+ const detail = error instanceof Error ? error.message : String(error);
85
+ throw new Error(`backend-test unique JSON object ${label}: invalid JSON (${detail})`);
86
+ }
87
+ if (!isPlainObject(parsed)) {
88
+ throw new Error(`backend-test unique JSON object ${label}: JSON root must be an object`);
89
+ }
90
+ return parsed;
91
+ }
92
+ function countJsonFenceOpenings(text) {
93
+ return [...text.matchAll(/```json\b/gi)].length;
94
+ }
95
+ function findUniqueJsonObjectText(text) {
96
+ const trimmed = text.trim();
97
+ if (!trimmed) {
98
+ throw new Error("backend-test unique JSON object: empty input; expected one unique JSON object");
99
+ }
100
+ // Pure object fast path must run before Markdown recognition so fence-like
101
+ // tokens inside JSON strings remain ordinary string content.
102
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
103
+ try {
104
+ const parsed = JSON.parse(trimmed);
105
+ if (isPlainObject(parsed)) {
106
+ return trimmed;
107
+ }
108
+ }
109
+ catch {
110
+ // Fall through to fenced / balanced candidate discovery.
111
+ }
112
+ }
113
+ const fenceOpenings = countJsonFenceOpenings(trimmed);
114
+ const fences = collectFencedJsonBlocks(trimmed);
115
+ if (fenceOpenings > fences.length) {
116
+ throw new Error("backend-test unique JSON object: unclosed fenced json block; expected one unique fenced json block");
117
+ }
118
+ if (fences.length > 1) {
119
+ throw new Error(`backend-test unique JSON object: expected exactly one fenced json block, found ${fences.length}`);
120
+ }
121
+ if (fences.length === 1) {
122
+ const fence = fences[0];
123
+ const outsideScans = [trimmed.slice(0, fence.start), trimmed.slice(fence.end)]
124
+ .map((fragment) => scanBalancedObjectSpans(fragment));
125
+ if (outsideScans.some((scan) => scan.unclosedObject)) {
126
+ throw new Error("backend-test unique JSON object: unclosed JSON object outside fenced json block");
127
+ }
128
+ const outsideSpans = outsideScans.flatMap((scan) => scan.spans);
129
+ if (outsideSpans.length > 0) {
130
+ throw new Error(`backend-test unique JSON object: expected one unique JSON object, found fenced json plus ${outsideSpans.length} bare object candidate(s)`);
131
+ }
132
+ return fence.body;
133
+ }
134
+ const scan = scanBalancedObjectSpans(trimmed);
135
+ if (scan.unclosedObject) {
136
+ throw new Error("backend-test unique JSON object: unclosed JSON object; expected one unique balanced object");
137
+ }
138
+ if (scan.spans.length === 0) {
139
+ throw new Error("backend-test unique JSON object: no unique JSON object found (no fenced json and no balanced bare object)");
140
+ }
141
+ if (scan.spans.length > 1) {
142
+ throw new Error(`backend-test unique JSON object: expected exactly one top-level JSON object, found ${scan.spans.length}`);
143
+ }
144
+ const span = scan.spans[0];
145
+ return trimmed.slice(span.start, span.end);
146
+ }
147
+ /**
148
+ * Extract exactly one plain JSON object from model text.
149
+ * Does not require analysis/execution fields.
150
+ */
151
+ export function extractUniqueJsonObject(text) {
152
+ const jsonText = findUniqueJsonObjectText(text);
153
+ return parseObjectJson(jsonText, "extract");
154
+ }
155
+ /**
156
+ * Extract the unique backend-test contract envelope `{ analysis, execution }` from model text.
157
+ */
158
+ export function extractBackendTestContractEnvelope(text) {
159
+ const envelope = extractUniqueJsonObject(text);
160
+ if (!envelope.analysis || !envelope.execution) {
161
+ throw new Error("backend-test contract envelope requires analysis and execution");
162
+ }
163
+ return {
164
+ analysis: envelope.analysis,
165
+ execution: envelope.execution,
166
+ };
167
+ }
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
+ import { extractUniqueJsonObject } from "./backend-test-contract-envelope.js";
6
7
  export const BACKEND_TEST_SEMANTIC_REVIEW_SCHEMA_ID = "backend-test-semantic-review-v1";
7
8
  export const backendTestSemanticReviewSchema = z.object({
8
9
  verdict: z.enum(["pass", "request-revision"]),
@@ -27,8 +28,7 @@ export const backendTestSemanticReviewSchema = z.object({
27
28
  export async function materializeBackendTestSemanticReview(input) {
28
29
  const wrapper = JSON.parse(await readFile(path.join(input.runDir, `${input.fromNodeId}.json`), "utf8"));
29
30
  const raw = wrapper.assistantText?.trim() || wrapper.stdout?.trim() || "";
30
- const fenced = raw.match(/^```json\s*([\s\S]*?)\s*```$/i);
31
- const parsed = backendTestSemanticReviewSchema.parse(JSON.parse(fenced ? fenced[1] : raw));
31
+ const parsed = backendTestSemanticReviewSchema.parse(extractUniqueJsonObject(raw));
32
32
  const relativePath = path.posix.join(input.outputDir, input.artifactName);
33
33
  const artifactPath = await writeDagRunJsonArtifact(input.runDir, relativePath, parsed);
34
34
  const normalized = `${JSON.stringify(parsed, null, 2)}\n`;