@tea-agent/loop-agent 0.26.1 → 0.26.2

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 +24 -0
  2. package/dist/application/dag/generate-task-dag.js +33 -0
  3. package/dist/commands/task-source-prepare.js +6 -0
  4. package/dist/executors/shell-executor.js +111 -0
  5. package/dist/executors/shell-presets.js +12 -4
  6. package/dist/task/config-types.js +6 -0
  7. package/dist/task/contract/constants.js +1 -0
  8. package/dist/task/contract/project.js +8 -0
  9. package/dist/task/contract/schema.js +1 -0
  10. package/dist/task/frontend-preflight.js +131 -0
  11. package/dist/task/runtime.js +2 -4
  12. package/dist/task/source-prepare/build-draft.js +9 -0
  13. package/dist/task/source-prepare/completeness.js +1 -1
  14. package/dist/worker/observability/read-model.js +134 -0
  15. package/dist/worker/observe/static/state.js +61 -0
  16. package/dist/worker/observe/static/styles.css +8 -0
  17. package/dist/worker/observe/static/views/dag-graph.js +107 -31
  18. package/dist/worker/observe/static/views/dag-inspector.js +374 -157
  19. package/dist/worker/observe/static/views/dag.js +4 -11
  20. package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
  21. package/dist/workflows/dag/convergence/controller.js +110 -21
  22. package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
  23. package/dist/workflows/dag/frontend-review-context.js +7 -1
  24. package/dist/workflows/dag/frontend-verification-trace.js +14 -3
  25. package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
  26. package/dist/workflows/dag/init-hybrid.js +96 -34
  27. package/dist/workflows/dag/output-protocol.js +180 -7
  28. package/dist/workflows/dag/runner.js +141 -52
  29. package/dist/workflows/dag/types.js +4 -0
  30. package/dist/workflows/dag/validate.js +3 -2
  31. package/docs/templates/backend-test-dag.json +100 -8
  32. package/package.json +1 -1
  33. package/skills/loop-agent/references/hybrid-dag.md +1 -1
@@ -84,6 +84,7 @@ export function loadFrontendImplementationContractJsonSchema(startDir = path.dir
84
84
  return JSON.stringify(parsed);
85
85
  }
86
86
  const id = z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/);
87
+ const REQUIREMENT_ID_PATTERN = /^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
87
88
  const safePath = z
88
89
  .string()
89
90
  .min(1)
@@ -366,7 +367,7 @@ export function canonicalFrontendContractSourceBinding(binding) {
366
367
  .filter((source) => source.kind === "reference")
367
368
  .map((source) => source.path)
368
369
  .sort(),
369
- requirementIds: [...binding.requirementIds],
370
+ requirementIds: binding.requirementIds.map(canonicalizeRequirementId),
370
371
  };
371
372
  }
372
373
  function asRecord(value) {
@@ -384,6 +385,137 @@ function asStringArray(value) {
384
385
  .map((item) => asString(item))
385
386
  .filter((item) => item.length > 0);
386
387
  }
388
+ function isRequirementId(value) {
389
+ return REQUIREMENT_ID_PATTERN.test(value);
390
+ }
391
+ /** Normalize the compact IDs models commonly emit (for example AC1) before
392
+ * strict schema validation. This keeps governance strict while making the
393
+ * boundary tolerant of presentation-only formatting differences. */
394
+ function canonicalizeRequirementId(value) {
395
+ const trimmed = value.trim().toUpperCase();
396
+ if (REQUIREMENT_ID_PATTERN.test(trimmed))
397
+ return trimmed;
398
+ const compact = /^(REQ|BR|AC)(\d+)$/.exec(trimmed);
399
+ if (compact)
400
+ return `${compact[1]}-${compact[2]}`;
401
+ return value;
402
+ }
403
+ function canonicalizeRequirementIdsInPayload(value) {
404
+ if (Array.isArray(value))
405
+ return value.map(canonicalizeRequirementIdsInPayload);
406
+ if (!value || typeof value !== "object")
407
+ return value;
408
+ const record = value;
409
+ const out = {};
410
+ for (const [key, child] of Object.entries(record)) {
411
+ if (key === "id" || key === "requirementId") {
412
+ out[key] = typeof child === "string" ? canonicalizeRequirementId(child) : child;
413
+ }
414
+ else if (key === "requirementIds" && Array.isArray(child)) {
415
+ out[key] = child.map((item) => typeof item === "string" ? canonicalizeRequirementId(item) : item);
416
+ }
417
+ else {
418
+ out[key] = canonicalizeRequirementIdsInPayload(child);
419
+ }
420
+ }
421
+ return out;
422
+ }
423
+ function canonicalizeVerificationTargetAliases(value) {
424
+ const record = asRecord(value);
425
+ if (!record || !Array.isArray(record.verificationTargets))
426
+ return value;
427
+ const targets = record.verificationTargets
428
+ .map((item) => asRecord(item))
429
+ .filter((item) => Boolean(item));
430
+ const aliases = new Map();
431
+ for (const target of targets) {
432
+ const id = asString(target.id);
433
+ const label = asString(target.commandLabel).toLowerCase();
434
+ if (!id)
435
+ continue;
436
+ aliases.set(id.toLowerCase(), id);
437
+ if (label.includes("typecheck"))
438
+ aliases.set("vt-typecheck", id);
439
+ if (label.includes("unified surface"))
440
+ aliases.set("vt-unified-surface", id);
441
+ }
442
+ const requirements = Array.isArray(record.requirements)
443
+ ? record.requirements.map((item) => {
444
+ const requirement = asRecord(item);
445
+ if (!requirement || !Array.isArray(requirement.verificationTargetIds))
446
+ return item;
447
+ return {
448
+ ...requirement,
449
+ verificationTargetIds: requirement.verificationTargetIds.map((id) => typeof id === "string" ? aliases.get(id.toLowerCase()) ?? id : id),
450
+ };
451
+ })
452
+ : record.requirements;
453
+ return { ...record, requirements };
454
+ }
455
+ function assertFrontendContractPathsSafe(value) {
456
+ const record = asRecord(value);
457
+ if (!record)
458
+ return;
459
+ const pathFields = ["files", "file", "implementationTargets", "fixture", "consumer"];
460
+ for (const [key, child] of Object.entries(record)) {
461
+ if (pathFields.includes(key) && Array.isArray(child)) {
462
+ for (const item of child) {
463
+ if (typeof item === "string" && (item.startsWith("/") || item.includes("\\") || item.split("/").includes("..")))
464
+ throw new Error(`invalid-output: unsafe frontend contract path ${item}`);
465
+ }
466
+ }
467
+ else if (pathFields.includes(key) && typeof child === "string" && (child.startsWith("/") || child.includes("\\") || child.split("/").includes(".."))) {
468
+ throw new Error(`invalid-output: unsafe frontend contract path ${child}`);
469
+ }
470
+ if (Array.isArray(child))
471
+ child.forEach(assertFrontendContractPathsSafe);
472
+ else if (child && typeof child === "object")
473
+ assertFrontendContractPathsSafe(child);
474
+ }
475
+ }
476
+ function deriveContractRequirementIds(value) {
477
+ const record = asRecord(value);
478
+ if (!record)
479
+ return [];
480
+ const ids = [];
481
+ const push = (candidate) => {
482
+ const value = asString(candidate);
483
+ const canonical = canonicalizeRequirementId(value);
484
+ if (!canonical || !isRequirementId(canonical) || ids.includes(canonical))
485
+ return;
486
+ ids.push(canonical);
487
+ };
488
+ if (Array.isArray(record.requirements)) {
489
+ for (const item of record.requirements)
490
+ push(asRecord(item)?.id);
491
+ }
492
+ if (Array.isArray(record.requirementCoverage)) {
493
+ for (const item of record.requirementCoverage)
494
+ push(asRecord(item)?.id);
495
+ }
496
+ const rawVerification = Array.isArray(record.verificationTargets)
497
+ ? record.verificationTargets
498
+ : asRecord(record.verificationTargets)
499
+ ? Object.values(asRecord(record.verificationTargets))
500
+ : [];
501
+ for (const item of rawVerification) {
502
+ for (const requirementId of asStringArray(asRecord(item)?.requirementIds)) {
503
+ push(requirementId);
504
+ }
505
+ }
506
+ for (const item of Array.isArray(record.evidenceGaps) ? record.evidenceGaps : []) {
507
+ push(asRecord(item)?.requirementId);
508
+ }
509
+ return ids;
510
+ }
511
+ function withDerivedRequirementIdsWhenUnscoped(binding, parsed) {
512
+ if (binding.requirementIds.length > 0)
513
+ return binding;
514
+ const derivedIds = deriveContractRequirementIds(parsed);
515
+ return derivedIds.length > 0
516
+ ? { ...binding, requirementIds: derivedIds }
517
+ : binding;
518
+ }
387
519
  function looksLikeStrictFrontendContract(value) {
388
520
  const record = asRecord(value);
389
521
  if (!record)
@@ -401,6 +533,11 @@ function looksLikeStrictFrontendContract(value) {
401
533
  */
402
534
  export function coerceFrontendImplementationContractInput(value, canonicalBinding) {
403
535
  const rawRecord = asRecord(value);
536
+ const rawMockApi = asRecord(rawRecord?.mockApi);
537
+ if (rawMockApi &&
538
+ typeof rawMockApi.strategy === "string" &&
539
+ !["native", "browser-intercept", "request-adapter", "not-needed"].includes(rawMockApi.strategy))
540
+ return value;
404
541
  const verificationTargetIds = rawRecord && Array.isArray(rawRecord.verificationTargets)
405
542
  ? new Set(rawRecord.verificationTargets
406
543
  .map((item) => asString(asRecord(item)?.id))
@@ -414,9 +551,10 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
414
551
  const requirement = asRecord(item);
415
552
  if (!requirement || !Array.isArray(requirement.verificationTargetIds))
416
553
  return item;
554
+ const filtered = requirement.verificationTargetIds.filter((id) => typeof id === "string" && verificationTargetIds.has(id));
417
555
  return {
418
556
  ...requirement,
419
- verificationTargetIds: requirement.verificationTargetIds.filter((id) => typeof id === "string" && verificationTargetIds.has(id)),
557
+ verificationTargetIds: filtered.length > 0 ? filtered : requirement.verificationTargetIds,
420
558
  };
421
559
  })
422
560
  : rawRecord.requirements,
@@ -444,8 +582,39 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
444
582
  : rawRecord.uiStates,
445
583
  }
446
584
  : value;
447
- if (looksLikeStrictFrontendContract(normalizedValue))
448
- return normalizedValue;
585
+ // A payload can have the strict top-level shape while still containing
586
+ // empty requirement coverage arrays. Do not trust shape alone: route such
587
+ // payloads through the compatibility normalizer so targets and verification
588
+ // references are deterministically filled from the contract context.
589
+ if (looksLikeStrictFrontendContract(normalizedValue)) {
590
+ const strictRecord = asRecord(normalizedValue);
591
+ const strictMockApi = asRecord(strictRecord?.mockApi);
592
+ if (strictMockApi &&
593
+ typeof strictMockApi.strategy === "string" &&
594
+ !["native", "browser-intercept", "request-adapter", "not-needed"].includes(strictMockApi.strategy))
595
+ return normalizedValue;
596
+ const targetFiles = asStringArray(asRecord(strictRecord?.targets)?.files);
597
+ const verificationIds = Array.isArray(strictRecord?.verificationTargets)
598
+ ? strictRecord.verificationTargets.map((item) => asString(asRecord(item)?.id)).filter(Boolean)
599
+ : [];
600
+ if (Array.isArray(strictRecord?.requirements)) {
601
+ const requirements = strictRecord.requirements.map((item) => {
602
+ const requirement = asRecord(item);
603
+ if (!requirement)
604
+ return item;
605
+ return {
606
+ ...requirement,
607
+ implementationTargets: asStringArray(requirement.implementationTargets).length > 0
608
+ ? requirement.implementationTargets
609
+ : targetFiles,
610
+ verificationTargetIds: asStringArray(requirement.verificationTargetIds).length > 0
611
+ ? requirement.verificationTargetIds
612
+ : verificationIds,
613
+ };
614
+ });
615
+ return { ...strictRecord, requirements };
616
+ }
617
+ }
449
618
  const record = asRecord(normalizedValue);
450
619
  if (!record)
451
620
  return value;
@@ -729,8 +898,15 @@ export async function materializeFrontendImplementationContract(input) {
729
898
  throw new Error("frontend implementation contract gate requires DAG sourceBinding");
730
899
  const record = JSON.parse(await readFile(path.join(input.runDir, `${input.fromNodeId}.json`), "utf8"));
731
900
  let parsed;
901
+ const rawContractText = record.assistantText?.trim() || record.stdout?.trim() || "";
902
+ if (rawContractText.includes('"files":["/'))
903
+ throw new Error("invalid-output: absolute frontend contract path is forbidden");
904
+ if (/(?:"(?:files|file|implementationTargets|fixture|consumer)"\s*:\s*(?:\[\s*)?)"\//.test(rawContractText))
905
+ throw new Error("invalid-output: absolute frontend contract path is forbidden");
906
+ if (/(?:"strategy"\s*:\s*")(?!native\b|browser-intercept\b|request-adapter\b|not-needed\b)[^"]+"/.test(rawContractText))
907
+ throw new Error("invalid-output: unsupported mock strategy");
732
908
  try {
733
- parsed = extractFrontendImplementationJson(record.assistantText?.trim() || record.stdout?.trim() || "");
909
+ parsed = extractFrontendImplementationJson(rawContractText);
734
910
  }
735
911
  catch (error) {
736
912
  throw new Error(`invalid-output: ${error instanceof Error ? error.message : String(error)}`);
@@ -740,21 +916,46 @@ export async function materializeFrontendImplementationContract(input) {
740
916
  throw new Error(`invalid-output: ${secrets.join("; ")}`);
741
917
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
742
918
  throw new Error("invalid-output: frontend contract must be a JSON object");
743
- const canonicalBinding = canonicalFrontendContractSourceBinding(input.sourceBinding);
919
+ const parsedTargetObject = parsed.targets;
920
+ const parsedFiles = parsedTargetObject && typeof parsedTargetObject === "object"
921
+ ? parsedTargetObject.files
922
+ : undefined;
923
+ if (Array.isArray(parsedFiles) && parsedFiles.some((file) => typeof file === "string" && file.startsWith("/")))
924
+ throw new Error("invalid-output: absolute frontend contract path is forbidden");
925
+ // Normalize model-emitted compact requirement IDs before deriving the
926
+ // canonical binding or invoking the strict zod schema.
927
+ parsed = canonicalizeRequirementIdsInPayload(parsed);
928
+ parsed = canonicalizeVerificationTargetAliases(parsed);
929
+ assertFrontendContractPathsSafe(parsed);
930
+ const parsedTargets = asRecord(parsed)?.targets;
931
+ const parsedTargetFiles = asStringArray(asRecord(parsedTargets)?.files);
932
+ if (parsedTargetFiles.some((file) => file.startsWith("/") || file.includes("\\")))
933
+ throw new Error("invalid-output: frontend contract target paths must be relative POSIX paths");
934
+ const parsedStates = asRecord(parsed)?.uiStates;
935
+ if (Array.isArray(parsedStates) && parsedStates.some((item) => {
936
+ const state = asRecord(item);
937
+ return state?.applicable === true &&
938
+ (!asString(state.expectedBehavior) || asStringArray(state.implementationTargets).length === 0 || asStringArray(state.verificationTargetIds).length === 0);
939
+ }))
940
+ throw new Error("invalid-output: applicable UI state requires behavior, implementation, and verification");
941
+ const parsedMockApi = asRecord(parsed)?.mockApi;
942
+ if (asRecord(parsedMockApi) &&
943
+ typeof asRecord(parsedMockApi)?.strategy === "string" &&
944
+ !["native", "browser-intercept", "request-adapter", "not-needed"].includes(String(asRecord(parsedMockApi)?.strategy)))
945
+ throw new Error(`invalid-output: unsupported mock strategy ${String(asRecord(parsedMockApi)?.strategy)}`);
946
+ const baseCanonicalBinding = canonicalFrontendContractSourceBinding(input.sourceBinding);
947
+ const canonicalBinding = withDerivedRequirementIdsWhenUnscoped(baseCanonicalBinding, parsed);
744
948
  // Always inject DAG-owned identity. Model-provided sourceBinding is advisory
745
949
  // only and must not fail a otherwise-valid contract (common live failure:
746
950
  // wrong requirementPath/sha, extra referencePaths, or omitted binding).
747
- const candidates = [
748
- {
749
- ...parsed,
750
- sourceBinding: canonicalBinding,
751
- },
752
- coerceFrontendImplementationContractInput(parsed, canonicalBinding),
753
- ];
754
- let result = frontendImplementationContractSchema.safeParse(candidates[0]);
755
- if (!result.success) {
756
- result = frontendImplementationContractSchema.safeParse(candidates[1]);
757
- }
951
+ const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
952
+ // There is exactly one post-security candidate. A fallback candidate would
953
+ // allow malformed raw fields to bypass the boundary checks above.
954
+ const candidate = {
955
+ ...(asRecord(normalizedContract) ?? parsed),
956
+ sourceBinding: canonicalBinding,
957
+ };
958
+ const result = frontendImplementationContractSchema.safeParse(candidate);
758
959
  if (!result.success)
759
960
  throw new Error(`invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
760
961
  const blockingGaps = [
@@ -76,8 +76,14 @@ function assertReviewEvidence(input) {
76
76
  throw new Error("frontend review context invalid repair assessment");
77
77
  }
78
78
  export async function runFrontendReviewContextGate(input) {
79
- const diff = await runFrontendWorktreeDiffGate(input);
80
79
  const contract = await readRequiredJson(input.runDir, "contracts/frontend-implementation-contract.json");
80
+ const parsedContract = frontendImplementationContractSchema.safeParse(contract);
81
+ if (!parsedContract.success)
82
+ throw new Error("frontend review context invalid implementation contract");
83
+ const diff = await runFrontendWorktreeDiffGate({
84
+ ...input,
85
+ authorizedChangedPaths: parsedContract.data.targets.files,
86
+ });
81
87
  const verificationTrace = await readRequiredJson(input.runDir, "contracts/frontend-verification-trace.json");
82
88
  const repairAssessment = await readRequiredJson(input.runDir, "contracts/frontend-repair-assessment.json");
83
89
  const lintAssessment = await readOptionalLintAssessment(input.runDir);
@@ -30,6 +30,8 @@ function symbolEvidenceCandidates(symbol) {
30
30
  const normalized = symbol.trim().toLowerCase();
31
31
  if (normalized === "all describe blocks")
32
32
  return ["describe("];
33
+ // A legacy contract may encode several symbols plus prose in one label.
34
+ // Validate the actual slash-delimited symbols individually.
33
35
  const describeTitle = symbol.match(/^describe\((?:['"])(.+?)(?:['"])/i)?.[1];
34
36
  if (describeTitle)
35
37
  return [symbol, describeTitle, "describe("];
@@ -58,9 +60,18 @@ async function assertFileAndSymbol(input) {
58
60
  }
59
61
  if (input.symbol) {
60
62
  const content = await readFile(absolute, "utf8");
61
- const matched = symbolEvidenceCandidates(input.symbol).some((candidate) => content.includes(candidate));
62
- if (!matched) {
63
- issues.push(`symbol not found: ${input.symbol} in ${input.file}`);
63
+ const symbols = input.symbol.includes(" / ")
64
+ ? input.symbol
65
+ .split(/[((]/, 1)[0]
66
+ .split("/")
67
+ .map((symbol) => symbol.trim())
68
+ .filter(Boolean)
69
+ : [input.symbol];
70
+ for (const symbol of symbols) {
71
+ const matched = symbolEvidenceCandidates(symbol).some((candidate) => content.includes(candidate));
72
+ if (!matched) {
73
+ issues.push(`symbol not found: ${symbol} in ${input.file}`);
74
+ }
64
75
  }
65
76
  }
66
77
  return issues;
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
+ import { pathMatchesPattern } from "../../shared/git-progress.js";
6
7
  export const FRONTEND_WORKTREE_DIFF_SCHEMA_ID = "frontend-worktree-diff-v1";
7
8
  export const FRONTEND_WORKTREE_BASELINE_SCHEMA_ID = "frontend-worktree-baseline-v1";
8
9
  function runGit(cwd, args) {
@@ -128,17 +129,27 @@ export async function runFrontendWorktreeDiffGate(input) {
128
129
  }
129
130
  const changedFilesAll = splitLines(nameOnly.stdout).sort();
130
131
  const untrackedFilesAll = splitLines(untracked.stdout).sort();
132
+ const authorizedChangedPaths = input.authorizedChangedPaths ?? [];
131
133
  if (baseline) {
132
134
  for (const file of baseline.files) {
133
135
  const currentHash = await sha256File(path.join(root, file));
134
- if (currentHash !== baseline.hashes[file]) {
136
+ const authorizedOverlap = authorizedChangedPaths.some((pattern) => pathMatchesPattern(file, pattern));
137
+ if (currentHash !== baseline.hashes[file] && !authorizedOverlap) {
135
138
  throw new Error(`frontend worktree diff overlaps pre-existing change: ${file}`);
136
139
  }
137
140
  }
138
141
  }
139
142
  const baselineFiles = new Set(baseline?.files ?? []);
140
- const changedFiles = changedFilesAll.filter((file) => !baselineFiles.has(file));
141
- const untrackedFiles = untrackedFilesAll.filter((file) => !baselineFiles.has(file));
143
+ const changedFiles = changedFilesAll.filter((file) => {
144
+ if (!baselineFiles.has(file))
145
+ return true;
146
+ return authorizedChangedPaths.some((pattern) => pathMatchesPattern(file, pattern));
147
+ });
148
+ const untrackedFiles = untrackedFilesAll.filter((file) => {
149
+ if (!baselineFiles.has(file))
150
+ return true;
151
+ return authorizedChangedPaths.some((pattern) => pathMatchesPattern(file, pattern));
152
+ });
142
153
  const patchSource = baseline
143
154
  ? changedFiles.length > 0
144
155
  ? await runGit(root, ["diff", "--binary", "HEAD", "--", ...changedFiles])