@tea-agent/loop-agent 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/bin/agent-worker.js +0 -0
  3. package/dist/adapters/loop-agent.js +52 -0
  4. package/dist/commands/init.js +104 -0
  5. package/dist/executors/dag-pi-executor.js +26 -0
  6. package/dist/executors/pi-executor.js +111 -36
  7. package/dist/executors/pi-sdk-executor.js +105 -29
  8. package/dist/executors/shell-executor.js +215 -29
  9. package/dist/shared/openspec-spec.js +49 -0
  10. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  11. package/dist/worker/observability/read-model.js +28 -2
  12. package/dist/worker/observe/spec-evidence.js +12 -15
  13. package/dist/worker/observe/static/constants.js +5 -0
  14. package/dist/worker/observe/static/dag-helpers.js +22 -0
  15. package/dist/worker/observe/static/format-pool.js +22 -3
  16. package/dist/worker/observe/static/styles.css +32 -3
  17. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  18. package/dist/worker/observe/static/views/dag.js +5 -0
  19. package/dist/worker/run-task/run-task.js +16 -6
  20. package/dist/workflows/dag/backend-test-markdown-workflow.js +328 -97
  21. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  22. package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
  23. package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
  24. package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
  25. package/dist/workflows/dag/frontend-project-capability.js +11 -8
  26. package/dist/workflows/dag/frontend-repair.js +6 -4
  27. package/dist/workflows/dag/frontend-review-context.js +67 -0
  28. package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
  29. package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
  30. package/dist/workflows/dag/frontend-verification-trace.js +31 -1
  31. package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
  32. package/dist/workflows/dag/init-hybrid.js +370 -79
  33. package/dist/workflows/dag/lifecycle.js +60 -4
  34. package/dist/workflows/dag/liveness-policy.js +250 -0
  35. package/dist/workflows/dag/node-execution.js +49 -0
  36. package/dist/workflows/dag/runner.js +21 -1
  37. package/dist/workflows/dag/types.js +67 -1
  38. package/docs/README.md +5 -6
  39. package/docs/architecture/dag-execution.md +11 -0
  40. package/docs/architecture/facts-and-state.md +1 -0
  41. package/docs/architecture/worker-and-feature.md +10 -0
  42. package/docs/templates/agent-dag.schema.json +15 -5
  43. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  44. package/docs/templates/backend-test-dag.json +15 -15
  45. package/docs/templates/frontend-implementation-contract.schema.json +4 -3
  46. package/docs/templates/frontend-test-case-checklist.md +6 -2
  47. package/docs/templates/frontend-test-dag.json +2 -2
  48. package/harness.json +1 -1
  49. package/package.json +1 -1
  50. package/skills/frontend-design-review/SKILL.md +12 -10
  51. package/skills/frontend-design-review/references/review-checklist.md +4 -4
  52. package/skills/frontend-implementation/SKILL.md +2 -2
  53. package/skills/frontend-implementation/references/code-standards.md +4 -3
  54. package/skills/frontend-implementation/references/design-spec.md +19 -14
  55. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  56. package/skills/frontend-review/SKILL.md +15 -28
  57. package/skills/frontend-review/references/review-findings.md +16 -18
  58. package/skills/frontend-verification/SKILL.md +16 -13
  59. package/skills/frontend-verification/references/verification-checklist.md +18 -30
  60. package/skills/loop-agent/references/command-reference.md +2 -0
  61. package/skills/loop-agent/references/hybrid-dag.md +2 -2
@@ -231,6 +231,12 @@ export function parseJunitXml(xml) {
231
231
  const failureTag = body.match(/<failure\b([^>]*)>([\s\S]*?)<\/failure>|<failure\b([^>]*)\/>/i);
232
232
  const errorTag = body.match(/<error\b([^>]*)>([\s\S]*?)<\/error>|<error\b([^>]*)\/>/i);
233
233
  const skippedTag = /<skipped\b/i.test(body);
234
+ const caseStdout = body.match(/<system-out\b[^>]*>([\s\S]*?)<\/system-out>/i)?.[1];
235
+ const caseStderr = body.match(/<system-err\b[^>]*>([\s\S]*?)<\/system-err>/i)?.[1];
236
+ const capturedOutput = {
237
+ ...(caseStdout ? { stdout: decodeXmlEntities(caseStdout).trim() } : {}),
238
+ ...(caseStderr ? { stderr: decodeXmlEntities(caseStderr).trim() } : {}),
239
+ };
234
240
  if (failureTag) {
235
241
  caseFailed += 1;
236
242
  const fAttrs = failureTag[1] ?? failureTag[3] ?? "";
@@ -243,7 +249,7 @@ export function parseJunitXml(xml) {
243
249
  message: summary,
244
250
  kind: "failure",
245
251
  });
246
- cases.push({ classname, name, durationMs: caseDurationMs, status: "failure", message: summary, details: fBody || summary });
252
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "failure", message: summary, details: fBody || summary, ...capturedOutput });
247
253
  }
248
254
  else if (errorTag) {
249
255
  caseErrors += 1;
@@ -257,16 +263,16 @@ export function parseJunitXml(xml) {
257
263
  message: summary,
258
264
  kind: "error",
259
265
  });
260
- cases.push({ classname, name, durationMs: caseDurationMs, status: "error", message: summary, details: eBody || summary });
266
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "error", message: summary, details: eBody || summary, ...capturedOutput });
261
267
  }
262
268
  else if (skippedTag) {
263
269
  caseSkipped += 1;
264
270
  const skippedAttrs = body.match(/<skipped\b([^>]*)/i)?.[1] ?? "";
265
271
  const message = attr(skippedAttrs, "message");
266
- cases.push({ classname, name, durationMs: caseDurationMs, status: "skipped", ...(message ? { message } : {}) });
272
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "skipped", ...(message ? { message } : {}), ...capturedOutput });
267
273
  }
268
274
  else {
269
- cases.push({ classname, name, durationMs: caseDurationMs, status: "passed" });
275
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "passed", ...capturedOutput });
270
276
  }
271
277
  match = caseRe.exec(trimmed);
272
278
  }
@@ -1,11 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
3
+ import { readFile, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { z } from "zod";
7
7
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
8
8
  import { findPackageRoot } from "../../shared/package-metadata.js";
9
+ import { pathMatchesPattern } from "../../shared/git-progress.js";
9
10
  export const FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID = "frontend-implementation-contract-v1";
10
11
  /**
11
12
  * Load the canonical frontend-implementation-contract-v1 JSON Schema from the
@@ -122,7 +123,7 @@ export const frontendImplementationContractSchema = z
122
123
  verificationTargetIds: z.array(z.string().min(1)),
123
124
  evidenceGap: gap.optional(),
124
125
  })
125
- .strict()),
126
+ .strict()).min(1),
126
127
  uiStates: z.array(z
127
128
  .object({
128
129
  name: z.string().min(1),
@@ -132,7 +133,7 @@ export const frontendImplementationContractSchema = z
132
133
  verificationTargetIds: z.array(z.string().min(1)).optional(),
133
134
  notApplicableReason: z.string().min(1).optional(),
134
135
  })
135
- .strict()),
136
+ .strict()).min(1),
136
137
  interactions: z.array(z
137
138
  .object({
138
139
  name: z.string().min(1),
@@ -185,7 +186,7 @@ export const frontendImplementationContractSchema = z
185
186
  requirementIds: z.array(id),
186
187
  uiStates: z.array(z.string().min(1)),
187
188
  })
188
- .strict()),
189
+ .strict()).min(1),
189
190
  evidenceGaps: z.array(gap),
190
191
  })
191
192
  .strict()
@@ -198,6 +199,30 @@ export const frontendImplementationContractSchema = z
198
199
  path: ["verificationTargets"],
199
200
  });
200
201
  const known = new Set(value.sourceBinding.requirementIds);
202
+ const stateNames = new Set(value.uiStates.map((state) => state.name));
203
+ for (const target of value.verificationTargets) {
204
+ // Verification evidence may point to read-only project files such as
205
+ // tsconfig.json or a test entrypoint; implementation targets remain
206
+ // governed by targets.files and the writer writeSet.
207
+ for (const requirementId of target.requirementIds) {
208
+ if (!known.has(requirementId)) {
209
+ ctx.addIssue({
210
+ code: "custom",
211
+ message: `verification target references unknown requirement ${requirementId}`,
212
+ path: ["verificationTargets"],
213
+ });
214
+ }
215
+ }
216
+ for (const stateName of target.uiStates) {
217
+ if (!stateNames.has(stateName)) {
218
+ ctx.addIssue({
219
+ code: "custom",
220
+ message: `verification target references unknown UI state ${stateName}`,
221
+ path: ["verificationTargets"],
222
+ });
223
+ }
224
+ }
225
+ }
201
226
  for (const requirement of value.requirements) {
202
227
  if (!known.has(requirement.id))
203
228
  ctx.addIssue({
@@ -238,7 +263,70 @@ export const frontendImplementationContractSchema = z
238
263
  path: ["uiStates"],
239
264
  });
240
265
  }
266
+ if (value.mockApi.strategy !== "not-needed") {
267
+ if (value.mockApi.endpoints.length === 0) {
268
+ ctx.addIssue({
269
+ code: "custom",
270
+ message: "Mock strategy requires at least one endpoint",
271
+ path: ["mockApi", "endpoints"],
272
+ });
273
+ }
274
+ value.mockApi.endpoints.forEach((endpoint, index) => {
275
+ if (!endpoint.fixture) {
276
+ ctx.addIssue({
277
+ code: "custom",
278
+ message: "Mock endpoint requires a fixture path",
279
+ path: ["mockApi", "endpoints", index, "fixture"],
280
+ });
281
+ }
282
+ if (!endpoint.consumer) {
283
+ ctx.addIssue({
284
+ code: "custom",
285
+ message: "Mock endpoint requires a consumer path",
286
+ path: ["mockApi", "endpoints", index, "consumer"],
287
+ });
288
+ }
289
+ for (const [field, file] of [["fixture", endpoint.fixture], ["consumer", endpoint.consumer]]) {
290
+ if (file && !value.targets.files.some((pattern) => pathMatchesPattern(file, pattern))) {
291
+ ctx.addIssue({
292
+ code: "custom",
293
+ message: `Mock ${field} is outside contract targets: ${file}`,
294
+ path: ["mockApi", "endpoints", index, field],
295
+ });
296
+ }
297
+ }
298
+ });
299
+ }
241
300
  });
301
+ export async function assertFrontendSourceBindingFresh(input) {
302
+ for (const source of input.binding.sources) {
303
+ // DAG source bindings are task-relative (for example source/需求.md),
304
+ // while older bindings may already contain the repository-relative
305
+ // .harness/tasks/<taskId>/ prefix. Resolve both forms before hashing.
306
+ const taskDir = path.resolve(input.workspaceRoot, ".harness", "tasks", input.binding.taskId);
307
+ const absolute = source.path.startsWith(".harness/")
308
+ ? path.resolve(input.workspaceRoot, source.path)
309
+ : path.resolve(taskDir, source.path);
310
+ const relative = path.relative(input.workspaceRoot, absolute);
311
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
312
+ throw new Error(`frontend source binding escapes workspace: ${source.path}`);
313
+ }
314
+ let content;
315
+ try {
316
+ content = await readFile(absolute);
317
+ const info = await stat(absolute);
318
+ if (!info.isFile())
319
+ throw new Error("not a regular file");
320
+ }
321
+ catch (error) {
322
+ throw new Error(`frontend source binding file unavailable: ${source.path}: ${error instanceof Error ? error.message : String(error)}`);
323
+ }
324
+ const actual = createHash("sha256").update(content).digest("hex");
325
+ if (actual !== source.sha256) {
326
+ throw new Error(`frontend source binding is stale: ${source.path}`);
327
+ }
328
+ }
329
+ }
242
330
  const SECRET_KEY = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|credential|authorization)/i;
243
331
  const SECRET_VALUE = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|ghp|github_pat|xox[baprs]|AKIA)[-_A-Za-z0-9]{12,}\b)/;
244
332
  function secretIssues(value, at = "$", issues = []) {
@@ -314,9 +402,42 @@ function looksLikeStrictFrontendContract(value) {
314
402
  * Near-schema payloads are left untouched so unknown-key fail-closed still holds.
315
403
  */
316
404
  export function coerceFrontendImplementationContractInput(value, canonicalBinding) {
317
- if (looksLikeStrictFrontendContract(value))
318
- return value;
319
- const record = asRecord(value);
405
+ const rawRecord = asRecord(value);
406
+ const verificationTargetIds = rawRecord && Array.isArray(rawRecord.verificationTargets)
407
+ ? new Set(rawRecord.verificationTargets
408
+ .map((item) => asString(asRecord(item)?.id))
409
+ .filter(Boolean))
410
+ : undefined;
411
+ const normalizedValue = rawRecord
412
+ ? {
413
+ ...rawRecord,
414
+ requirements: verificationTargetIds && Array.isArray(rawRecord.requirements)
415
+ ? rawRecord.requirements.map((item) => {
416
+ const requirement = asRecord(item);
417
+ if (!requirement || !Array.isArray(requirement.verificationTargetIds))
418
+ return item;
419
+ return {
420
+ ...requirement,
421
+ verificationTargetIds: requirement.verificationTargetIds.filter((id) => typeof id === "string" && verificationTargetIds.has(id)),
422
+ };
423
+ })
424
+ : rawRecord.requirements,
425
+ uiStates: Array.isArray(rawRecord.uiStates)
426
+ ? rawRecord.uiStates.map((item) => {
427
+ const state = asRecord(item);
428
+ if (!state ||
429
+ state.applicable === false ||
430
+ (state.notApplicableReason !== "" && state.notApplicableReason !== null))
431
+ return item;
432
+ const { notApplicableReason: _emptyReason, ...withoutEmptyReason } = state;
433
+ return withoutEmptyReason;
434
+ })
435
+ : rawRecord.uiStates,
436
+ }
437
+ : value;
438
+ if (looksLikeStrictFrontendContract(normalizedValue))
439
+ return normalizedValue;
440
+ const record = asRecord(normalizedValue);
320
441
  if (!record)
321
442
  return value;
322
443
  const implementation = asRecord(record.implementation);
@@ -333,7 +454,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
333
454
  ...asStringArray(asRecord(component?.paths)?.domHelper ? [asRecord(component?.paths)?.domHelper] : []),
334
455
  ].filter((item, index, arr) => arr.indexOf(item) === index);
335
456
  if (targetFiles.length === 0) {
336
- targetFiles.push("apps/web/src/welcome/WelcomeBanner.js");
457
+ throw new Error("frontend contract compatibility input must declare target files");
337
458
  }
338
459
  const riskRaw = asString(record.riskLevel) ||
339
460
  asString(record.risk) ||
@@ -383,21 +504,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
383
504
  });
384
505
  }
385
506
  if (verificationTargets.length === 0) {
386
- verificationTargets.push({
387
- id: "VT-STATIC",
388
- type: "static",
389
- commandLabel: "npm run typecheck",
390
- file: targetFiles[0],
391
- requirementIds: [...canonicalBinding.requirementIds],
392
- uiStates: ["success"],
393
- }, {
394
- id: "VT-UNIT",
395
- type: "unit",
396
- commandLabel: "npm run test:unit:fe",
397
- file: targetFiles.find((p) => p.includes("__tests__")) || targetFiles[0],
398
- requirementIds: [...canonicalBinding.requirementIds],
399
- uiStates: ["success", "error"],
400
- });
507
+ throw new Error("frontend contract compatibility input must declare verification targets");
401
508
  }
402
509
  const defaultVerificationIds = verificationTargets.map((item) => String(item.id));
403
510
  const requirements = [];
@@ -440,11 +547,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
440
547
  }
441
548
  for (const id of canonicalBinding.requirementIds) {
442
549
  if (!requirements.some((item) => item.id === id)) {
443
- requirements.push({
444
- id,
445
- implementationTargets: targetFiles,
446
- verificationTargetIds: defaultVerificationIds,
447
- });
550
+ throw new Error(`frontend contract compatibility input does not cover ${id}`);
448
551
  }
449
552
  }
450
553
  const uiStates = [];
@@ -511,14 +614,20 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
511
614
  });
512
615
  }
513
616
  const strategyRaw = asString(mockApiIn.strategy) || "not-needed";
514
- const strategy = [
617
+ const allowedStrategies = [
515
618
  "native",
516
619
  "browser-intercept",
517
620
  "request-adapter",
518
621
  "not-needed",
519
- ].includes(strategyRaw)
520
- ? strategyRaw
521
- : "not-needed";
622
+ ];
623
+ if (!allowedStrategies.includes(strategyRaw)) {
624
+ throw new Error(`frontend contract compatibility input has unsupported Mock strategy: ${strategyRaw}`);
625
+ }
626
+ const strategy = strategyRaw;
627
+ const productionDefaultOff = mockApiIn.productionDefaultOff === true || mockApiIn.productionMockOff === true;
628
+ if (!productionDefaultOff) {
629
+ throw new Error("frontend contract compatibility input must prove production Mock is off");
630
+ }
522
631
  const activation = asString(mockApiIn.activation) ||
523
632
  (strategy === "not-needed"
524
633
  ? "production remains real fetch; unit tests may inject fetchImpl only"