@tea-agent/loop-agent 0.16.1 → 0.16.3

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 (33) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/executors/dag-pi-executor.js +4 -2
  3. package/dist/executors/pi-sdk-executor.js +66 -3
  4. package/dist/executors/shell-executor.js +239 -29
  5. package/dist/executors/shell-presets.js +12 -2
  6. package/dist/executors/shell-write-guard.js +20 -1
  7. package/dist/shared/git-progress.js +9 -2
  8. package/dist/worker/observability/read-model.js +56 -0
  9. package/dist/worker/observe/server.js +6 -3
  10. package/dist/workflows/dag/backend-test-analysis-contract.js +87 -30
  11. package/dist/workflows/dag/backend-test-case-manifest.js +71 -8
  12. package/dist/workflows/dag/backend-test-execution-contract.js +63 -11
  13. package/dist/workflows/dag/backend-test-repair-contract.js +94 -0
  14. package/dist/workflows/dag/backend-test-result-contract.js +6 -4
  15. package/dist/workflows/dag/backend-test-semantic-review-contract.js +36 -0
  16. package/dist/workflows/dag/dynamic-runtime/condition.js +1 -1
  17. package/dist/workflows/dag/dynamic-runtime/shared.js +42 -0
  18. package/dist/workflows/dag/failure-routing.js +1 -1
  19. package/dist/workflows/dag/frontend-implementation-contract.js +32 -16
  20. package/dist/workflows/dag/frontend-worktree-diff.js +127 -0
  21. package/dist/workflows/dag/init-hybrid.js +616 -120
  22. package/dist/workflows/dag/lifecycle.js +33 -2
  23. package/dist/workflows/dag/scheduler.js +87 -17
  24. package/dist/workflows/dag/types.js +31 -0
  25. package/dist/workflows/dag/validate.js +20 -14
  26. package/docs/templates/agent-dag.schema.json +25 -2
  27. package/docs/templates/backend-test-analysis.schema.json +9 -16
  28. package/docs/templates/backend-test-dag.json +493 -197
  29. package/docs/templates/backend-test-dag.review-cases.prompt.md +10 -4
  30. package/docs/templates/backend-test-execution.schema.json +6 -1
  31. package/package.json +1 -1
  32. package/skills/frontend-review/SKILL.md +6 -2
  33. package/skills/loop-agent/references/hybrid-dag.md +4 -1
@@ -186,6 +186,26 @@ export function extractFrontendImplementationJson(text) {
186
186
  throw new Error("output must contain exactly one fenced json object");
187
187
  return JSON.parse(blocks[0][1]);
188
188
  }
189
+ /**
190
+ * Build the authoritative frontend-implementation-contract sourceBinding from
191
+ * the DAG-owned binding. Model JSON must not invent taskId/path/sha256; the
192
+ * gate overwrites whatever the model emitted so identity cannot drift.
193
+ */
194
+ export function canonicalFrontendContractSourceBinding(binding) {
195
+ const requirement = binding.sources.find((source) => source.kind === "requirement");
196
+ if (!requirement)
197
+ throw new Error("frontend implementation contract gate requires a requirement source in DAG sourceBinding");
198
+ return {
199
+ taskId: binding.taskId,
200
+ requirementPath: requirement.path,
201
+ requirementSha256: requirement.sha256,
202
+ referencePaths: binding.sources
203
+ .filter((source) => source.kind === "reference")
204
+ .map((source) => source.path)
205
+ .sort(),
206
+ requirementIds: [...binding.requirementIds],
207
+ };
208
+ }
189
209
  export async function materializeFrontendImplementationContract(input) {
190
210
  if (!input.sourceBinding)
191
211
  throw new Error("frontend implementation contract gate requires DAG sourceBinding");
@@ -200,23 +220,19 @@ export async function materializeFrontendImplementationContract(input) {
200
220
  const secrets = secretIssues(parsed);
201
221
  if (secrets.length)
202
222
  throw new Error(`invalid-output: ${secrets.join("; ")}`);
203
- const result = frontendImplementationContractSchema.safeParse(parsed);
223
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
224
+ throw new Error("invalid-output: frontend contract must be a JSON object");
225
+ const canonicalBinding = canonicalFrontendContractSourceBinding(input.sourceBinding);
226
+ // Always inject DAG-owned identity. Model-provided sourceBinding is advisory
227
+ // only and must not fail a otherwise-valid contract (common live failure:
228
+ // wrong requirementPath/sha, extra referencePaths, or omitted binding).
229
+ const withCanonicalBinding = {
230
+ ...parsed,
231
+ sourceBinding: canonicalBinding,
232
+ };
233
+ const result = frontendImplementationContractSchema.safeParse(withCanonicalBinding);
204
234
  if (!result.success)
205
235
  throw new Error(`invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
206
- const requirement = input.sourceBinding.sources.find((source) => source.kind === "requirement");
207
- const refs = input.sourceBinding.sources
208
- .filter((source) => source.kind === "reference")
209
- .map((source) => source.path)
210
- .sort();
211
- const bound = result.data.sourceBinding;
212
- if (!requirement ||
213
- bound.taskId !== input.sourceBinding.taskId ||
214
- bound.requirementPath !== requirement.path ||
215
- bound.requirementSha256 !== requirement.sha256 ||
216
- JSON.stringify([...bound.referencePaths].sort()) !== JSON.stringify(refs) ||
217
- JSON.stringify(bound.requirementIds) !==
218
- JSON.stringify(input.sourceBinding.requirementIds))
219
- throw new Error("frontend contract source binding does not match DAG source binding");
220
236
  const blockingGaps = [
221
237
  ...result.data.evidenceGaps,
222
238
  ...result.data.requirements.flatMap((item) => item.evidenceGap ? [item.evidenceGap] : []),
@@ -225,7 +241,7 @@ export async function materializeFrontendImplementationContract(input) {
225
241
  throw new Error(`frontend contract has blocking evidence gap: ${blockingGaps
226
242
  .map((item) => item.requirementId ?? item.description)
227
243
  .join(", ")}`);
228
- for (const requirementId of bound.requirementIds)
244
+ for (const requirementId of canonicalBinding.requirementIds)
229
245
  if (!result.data.requirements.some((item) => item.id === requirementId) &&
230
246
  !result.data.evidenceGaps.some((item) => item.requirementId === requirementId))
231
247
  throw new Error(`frontend contract does not cover ${requirementId}`);
@@ -0,0 +1,127 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
+ export const FRONTEND_WORKTREE_DIFF_SCHEMA_ID = "frontend-worktree-diff-v1";
7
+ function runGit(cwd, args) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn("git", args, {
10
+ cwd,
11
+ env: process.env,
12
+ stdio: ["ignore", "pipe", "pipe"],
13
+ });
14
+ let stdout = "";
15
+ let stderr = "";
16
+ child.stdout.on("data", (chunk) => {
17
+ stdout += String(chunk);
18
+ });
19
+ child.stderr.on("data", (chunk) => {
20
+ stderr += String(chunk);
21
+ });
22
+ child.on("error", reject);
23
+ child.on("close", (code) => {
24
+ resolve({ code: code ?? 1, stdout, stderr });
25
+ });
26
+ });
27
+ }
28
+ function splitLines(text) {
29
+ return text
30
+ .split(/\r?\n/)
31
+ .map((line) => line.trim())
32
+ .filter(Boolean);
33
+ }
34
+ /**
35
+ * Capture the current workspace worktree as a run-owned review artifact.
36
+ *
37
+ * Frontend review is contractually required to inspect an "actual diff". The
38
+ * model cannot invent one, and failure-path `changes.patch` under Task Pool is
39
+ * not visible while the DAG is still active. This gate freezes a deterministic
40
+ * patch + inventory under the current run directory before review runs.
41
+ */
42
+ export async function runFrontendWorktreeDiffGate(input) {
43
+ const root = path.resolve(input.workspaceRoot);
44
+ const revParse = await runGit(root, ["rev-parse", "--is-inside-work-tree"]);
45
+ if (revParse.code !== 0 || revParse.stdout.trim() !== "true") {
46
+ throw new Error(`frontend worktree diff gate requires a git worktree: ${revParse.stderr.trim() || revParse.stdout.trim() || "not a git repository"}`);
47
+ }
48
+ // Match failure capture semantics: binary-capable tracked diff vs HEAD.
49
+ const diff = await runGit(root, ["diff", "--binary", "HEAD"]);
50
+ if (diff.code !== 0) {
51
+ throw new Error(`frontend worktree diff gate git diff failed: ${diff.stderr.trim() || diff.stdout.trim()}`);
52
+ }
53
+ const nameOnly = await runGit(root, [
54
+ "diff",
55
+ "--name-only",
56
+ "--diff-filter=ACDMRTUXB",
57
+ "HEAD",
58
+ ]);
59
+ if (nameOnly.code !== 0) {
60
+ throw new Error(`frontend worktree diff gate git name-only failed: ${nameOnly.stderr.trim() || nameOnly.stdout.trim()}`);
61
+ }
62
+ const untracked = await runGit(root, [
63
+ "ls-files",
64
+ "--others",
65
+ "--exclude-standard",
66
+ ]);
67
+ if (untracked.code !== 0) {
68
+ throw new Error(`frontend worktree diff gate ls-files failed: ${untracked.stderr.trim() || untracked.stdout.trim()}`);
69
+ }
70
+ const changedFiles = splitLines(nameOnly.stdout).sort();
71
+ const untrackedFiles = splitLines(untracked.stdout).sort();
72
+ const patchBody = diff.stdout.endsWith("\n") || diff.stdout.length === 0
73
+ ? diff.stdout
74
+ : `${diff.stdout}\n`;
75
+ const untrackedSection = untrackedFiles.length === 0
76
+ ? ""
77
+ : [
78
+ "",
79
+ "# Untracked files (inventory only; contents not inlined)",
80
+ ...untrackedFiles.map((file) => `# untracked: ${file}`),
81
+ "",
82
+ ].join("\n");
83
+ const patchText = `${patchBody}${untrackedSection}`;
84
+ const patchSha256 = createHash("sha256").update(patchText).digest("hex");
85
+ const relativePatch = "artifacts/diff_patch.patch";
86
+ const absolutePatch = path.join(input.runDir, ...relativePatch.split("/"));
87
+ await mkdir(path.dirname(absolutePatch), { recursive: true });
88
+ await writeFile(absolutePatch, patchText, "utf8");
89
+ const payload = {
90
+ schemaVersion: 1,
91
+ schemaId: FRONTEND_WORKTREE_DIFF_SCHEMA_ID,
92
+ patchPath: relativePatch,
93
+ patchSha256,
94
+ changedFiles,
95
+ untrackedFiles,
96
+ empty: changedFiles.length === 0 && untrackedFiles.length === 0,
97
+ capturedAt: new Date().toISOString(),
98
+ };
99
+ const artifactPath = await writeDagRunJsonArtifact(input.runDir, "contracts/frontend-worktree-diff.json", payload);
100
+ return {
101
+ ok: true,
102
+ schemaId: FRONTEND_WORKTREE_DIFF_SCHEMA_ID,
103
+ patchPath: relativePatch,
104
+ patchAbsolutePath: absolutePatch,
105
+ patchSha256,
106
+ changedFiles,
107
+ untrackedFiles,
108
+ empty: payload.empty,
109
+ capturedAt: payload.capturedAt,
110
+ artifactPath,
111
+ };
112
+ }
113
+ export function formatFrontendWorktreeDiffStdout(result) {
114
+ return [
115
+ `schemaId=${result.schemaId}`,
116
+ `patchPath=${result.patchPath}`,
117
+ `patchSha256=${result.patchSha256}`,
118
+ `changedFiles=${result.changedFiles.length}`,
119
+ `untrackedFiles=${result.untrackedFiles.length}`,
120
+ `empty=${result.empty ? "true" : "false"}`,
121
+ result.artifactPath ? `artifactPath=${result.artifactPath}` : "",
122
+ ...result.changedFiles.map((file) => `changed:${file}`),
123
+ ...result.untrackedFiles.map((file) => `untracked:${file}`),
124
+ ]
125
+ .filter(Boolean)
126
+ .join("\n");
127
+ }