@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
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { access, readdir, readFile, realpath, writeFile, } from "node:fs/promises";
3
+ import { existsSync, readFileSync } from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { assertValidDagSpec } from "./validate.js";
@@ -829,6 +830,9 @@ function extractFrontendMockVerifyCommandsFromMarkdown(input) {
829
830
  return commands.filter((command, index, all) => all.findIndex((candidate) => candidate.args.join("\0") === command.args.join("\0")) === index);
830
831
  }
831
832
  function chooseFrontendVerifyCommands(input) {
833
+ if (input.explicitCommands && input.explicitCommands.length > 0) {
834
+ return { commands: input.explicitCommands, commandSource: "inline" };
835
+ }
832
836
  if (input.parsedCommands.length > 0) {
833
837
  return { commands: input.parsedCommands, commandSource: "inline" };
834
838
  }
@@ -837,6 +841,36 @@ function chooseFrontendVerifyCommands(input) {
837
841
  }
838
842
  return { commandSource: "inline" };
839
843
  }
844
+ function classifyFrontendVerifyCommand(command) {
845
+ return /\b(typecheck|check-types|lint|eslint|tsc|build|check)\b/i.test(command) ||
846
+ /\bscripts[\\/]+ci(?:-tests)?\.sh\b/i.test(command)
847
+ ? "static"
848
+ : "behavior";
849
+ }
850
+ function buildExplicitFrontendVerifyCommands(taskConfig, repoRoot) {
851
+ const staticCommands = [];
852
+ const behaviorCommands = [];
853
+ if (!repoRoot)
854
+ return { staticCommands, behaviorCommands };
855
+ for (const command of taskConfig.verifyCommands) {
856
+ const verifyCommand = {
857
+ args: ["bash", "-lc", command.command],
858
+ cwd: repoRoot,
859
+ label: command.label,
860
+ timeoutMs: command.timeoutMs,
861
+ };
862
+ if (classifyFrontendVerifyCommand(command.command) === "static") {
863
+ staticCommands.push(verifyCommand);
864
+ }
865
+ else {
866
+ behaviorCommands.push(verifyCommand);
867
+ }
868
+ }
869
+ return { staticCommands, behaviorCommands };
870
+ }
871
+ function verifyCommandKey(command) {
872
+ return `${command.cwd}\0${command.args.join("\0")}`;
873
+ }
840
874
  function resolveDagVerifyStrategy(taskConfig) {
841
875
  return {
842
876
  intermediateQuota: taskConfig.dagVerifyStrategy?.intermediateQuota ?? taskConfig.verifyQuota,
@@ -851,19 +885,80 @@ function buildVerifyEvidence(input) {
851
885
  commandSource: input.commandSource,
852
886
  commandCount: input.commands?.length ?? input.fallbackCommands.length,
853
887
  commandLabels: input.commands?.map((command) => command.label) ?? input.fallbackCommands,
888
+ commandTexts: input.commandTexts ?? [],
854
889
  finalFullRequired: input.finalFullRequired,
855
890
  };
856
891
  }
892
+ function isFrontendLintVerifyCommand(command) {
893
+ const text = `${command.label}\n${command.args.join(" ")}`;
894
+ return /\b(?:lint|eslint)\b/i.test(text);
895
+ }
896
+ function isManagedCiWrapperVerifyCommand(command) {
897
+ const text = `${command.label}\n${command.args.join(" ")}`.replaceAll("\\", "/");
898
+ return /\bscripts\/ci(?:-tests)?\.sh\b/.test(text);
899
+ }
900
+ function repoHasNpmScript(repoRoot, scriptName) {
901
+ if (!repoRoot)
902
+ return false;
903
+ const packagePath = path.join(repoRoot, "package.json");
904
+ if (!existsSync(packagePath))
905
+ return false;
906
+ try {
907
+ const decoded = JSON.parse(readFileSync(packagePath, "utf8"));
908
+ return typeof decoded.scripts?.[scriptName] === "string";
909
+ }
910
+ catch {
911
+ return false;
912
+ }
913
+ }
914
+ function partitionFrontendStaticVerifyCommands(input) {
915
+ const commands = input.commands ?? [];
916
+ const lintCommands = commands.filter(isFrontendLintVerifyCommand);
917
+ const staticCommands = commands.filter((command) => !isFrontendLintVerifyCommand(command));
918
+ if (lintCommands.length === 0 &&
919
+ commands.some(isManagedCiWrapperVerifyCommand) &&
920
+ repoHasNpmScript(input.repoRoot, "lint")) {
921
+ lintCommands.push({
922
+ args: ["npm", "run", "lint"],
923
+ cwd: input.repoRoot,
924
+ label: "npm run lint",
925
+ });
926
+ }
927
+ return {
928
+ lint: {
929
+ ...(lintCommands.length > 0 ? { commands: lintCommands } : {}),
930
+ commandSource: input.commandSource,
931
+ },
932
+ static: {
933
+ ...(staticCommands.length > 0 ? { commands: staticCommands } : {}),
934
+ commandSource: input.commandSource,
935
+ },
936
+ };
937
+ }
938
+ function isFrontendTestPathPattern(entry) {
939
+ const segments = entry.replaceAll("\\", "/").split("/");
940
+ return segments.some((segment) => /^(?:test|tests|spec|specs|e2e|__tests__|__specs__)$/i.test(segment));
941
+ }
942
+ function replaceSourceSegmentWithTestSegment(entry) {
943
+ const normalized = entry.replaceAll("\\", "/");
944
+ const segments = normalized.split("/");
945
+ const sourceIndex = segments.findIndex((segment) => /^(?:src|source|app|lib)$/i.test(segment));
946
+ if (sourceIndex < 0)
947
+ return null;
948
+ const testSegments = [...segments];
949
+ testSegments[sourceIndex] = "test";
950
+ return testSegments.join("/");
951
+ }
857
952
  function deriveParallelScoutPaths(taskConfig) {
858
953
  const allowed = taskConfig.allowedPaths;
859
954
  if (allowed.length === 0) {
860
955
  return { srcPaths: ["**"], testPaths: ["**"] };
861
956
  }
862
- const srcPaths = allowed.filter((entry) => !entry.includes("/test/"));
863
- const explicitTestPaths = allowed.filter((entry) => entry.includes("/test/"));
957
+ const srcPaths = allowed.filter((entry) => !isFrontendTestPathPattern(entry));
958
+ const explicitTestPaths = allowed.filter(isFrontendTestPathPattern);
864
959
  const derivedTestPaths = allowed
865
- .filter((entry) => entry.includes("/src/"))
866
- .map((entry) => entry.replace("/src/", "/test/"));
960
+ .map(replaceSourceSegmentWithTestSegment)
961
+ .filter((entry) => Boolean(entry));
867
962
  return {
868
963
  srcPaths: srcPaths.length > 0 ? srcPaths : allowed,
869
964
  testPaths: explicitTestPaths.length > 0
@@ -873,6 +968,80 @@ function deriveParallelScoutPaths(taskConfig) {
873
968
  : allowed,
874
969
  };
875
970
  }
971
+ /**
972
+ * Resolve frontend verification fallbacks from the target project's own
973
+ * package scripts. The DAG builder is also used by unit fixtures without a
974
+ * package.json, so those fixtures retain the historical generic fallback.
975
+ * Real projects never inherit loop-agent's commands when package.json exists.
976
+ */
977
+ async function discoverFrontendFallbackVerifyCommands(repoRoot) {
978
+ const genericFallback = {
979
+ staticCommands: ["npm run typecheck", "npm run build"],
980
+ behaviorCommands: ["npm test"],
981
+ };
982
+ if (!repoRoot)
983
+ return genericFallback;
984
+ let scripts;
985
+ try {
986
+ const packageJson = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
987
+ if (packageJson.scripts && typeof packageJson.scripts === "object") {
988
+ scripts = packageJson.scripts;
989
+ }
990
+ }
991
+ catch {
992
+ return genericFallback;
993
+ }
994
+ if (!scripts)
995
+ return genericFallback;
996
+ let packageManager = "npm";
997
+ for (const [lockfile, manager] of [
998
+ ["pnpm-lock.yaml", "pnpm"],
999
+ ["yarn.lock", "yarn"],
1000
+ ["bun.lockb", "bun"],
1001
+ ["bun.lock", "bun"],
1002
+ ]) {
1003
+ try {
1004
+ await access(path.join(repoRoot, lockfile));
1005
+ packageManager = manager;
1006
+ break;
1007
+ }
1008
+ catch {
1009
+ // Try the next package-manager marker.
1010
+ }
1011
+ }
1012
+ const commandForScript = (script) => script === "test"
1013
+ ? `${packageManager} test`
1014
+ : `${packageManager} run ${script}`;
1015
+ const hasScript = (script) => typeof scripts?.[script] === "string" &&
1016
+ String(scripts[script]).trim().length > 0;
1017
+ const firstExisting = (names) => {
1018
+ const selected = [];
1019
+ for (const name of names) {
1020
+ if (hasScript(name) && !selected.includes(name))
1021
+ selected.push(name);
1022
+ }
1023
+ return selected.map(commandForScript);
1024
+ };
1025
+ const staticCommands = firstExisting([
1026
+ "typecheck",
1027
+ "check-types",
1028
+ "lint",
1029
+ "check",
1030
+ "build",
1031
+ ]);
1032
+ const behaviorCommands = firstExisting([
1033
+ "test:unit",
1034
+ "test:frontend",
1035
+ "test:component",
1036
+ "test",
1037
+ "test:e2e",
1038
+ "e2e",
1039
+ ]);
1040
+ return {
1041
+ staticCommands: staticCommands.length > 0 ? staticCommands : behaviorCommands,
1042
+ behaviorCommands: behaviorCommands.length > 0 ? behaviorCommands : staticCommands,
1043
+ };
1044
+ }
876
1045
  function deriveFrontendBehaviorPaths(taskConfig) {
877
1046
  if (taskConfig.allowedPaths.length === 0)
878
1047
  return ["**"];
@@ -1507,6 +1676,7 @@ function buildFrontendMockVerifyNode(sources, implementId, readOnlyPaths, forbid
1507
1676
  commandSource: commands.length > 0 ? "inline" : "adapter",
1508
1677
  commands: verifyCommands.length > 0 ? verifyCommands : undefined,
1509
1678
  fallbackCommands: [],
1679
+ commandTexts: commands,
1510
1680
  }),
1511
1681
  cwd: ".",
1512
1682
  timeoutMs: 300000,
@@ -1688,7 +1858,7 @@ function pruneFrontendTasksForRisk(tasks, risk) {
1688
1858
  return { ...task, depends_on };
1689
1859
  });
1690
1860
  }
1691
- function buildFrontendHybridDagFromTask(sources) {
1861
+ async function buildFrontendHybridDagFromTask(sources) {
1692
1862
  const { taskConfig } = sources;
1693
1863
  const mockCapability = sources.frontendMockCapability ?? {
1694
1864
  status: "absent",
@@ -1756,6 +1926,8 @@ function buildFrontendHybridDagFromTask(sources) {
1756
1926
  "- verificationTargets is a TOP-LEVEL required array",
1757
1927
  "- uiStates items use name/applicable/expectedBehavior/implementationTargets/verificationTargetIds/notApplicableReason",
1758
1928
  "- mockApi.productionDefaultOff must always be true (including strategy: not-needed)",
1929
+ "- All implementation files, verification files, symbols, and commands must be discovered from the current target workspace and current task. Never copy paths, symbols, or commands from the loop-agent repository, an example task, or prior run output.",
1930
+ "- Use relative POSIX paths rooted at the target workspace. Do not assume a particular src/test directory layout; preserve the target project's actual app/, packages/, spec/, __tests__, or other layout.",
1759
1931
  ].join("\n");
1760
1932
  })();
1761
1933
  const sourceContext = [
@@ -1797,26 +1969,42 @@ function buildFrontendHybridDagFromTask(sources) {
1797
1969
  if (mockMode === "blocked") {
1798
1970
  return buildBlockedFrontendMockDag(frontendSources, readOnlyPaths, forbiddenPaths, globalConstraints);
1799
1971
  }
1800
- const staticFallbackCommands = ["npm run typecheck", "npm run build"];
1801
- const behaviorFallbackCommands = ["npm test"];
1972
+ const fallbackVerifyCommands = await discoverFrontendFallbackVerifyCommands(sources.repoRoot);
1973
+ const staticFallbackCommands = fallbackVerifyCommands.staticCommands;
1974
+ const behaviorFallbackCommands = fallbackVerifyCommands.behaviorCommands;
1802
1975
  const parsedFrontendVerifyCommands = extractFrontendVerifyCommandsFromMarkdown({
1803
1976
  repoRoot: sources.repoRoot,
1804
1977
  requirementMarkdown: sources.requirementMarkdown,
1805
1978
  constraintMarkdown: sources.constraintMarkdown,
1806
1979
  });
1980
+ const explicitFrontendVerifyCommands = buildExplicitFrontendVerifyCommands(taskConfig, sources.repoRoot);
1981
+ const explicitCommandKeys = new Set([...explicitFrontendVerifyCommands.staticCommands, ...explicitFrontendVerifyCommands.behaviorCommands].map(verifyCommandKey));
1982
+ const adapterVerifyCommands = (sources.verifyCommands?.final ?? []).filter((command) => !explicitCommandKeys.has(verifyCommandKey(command)));
1807
1983
  const staticVerifyCommands = chooseFrontendVerifyCommands({
1984
+ explicitCommands: explicitFrontendVerifyCommands.staticCommands,
1808
1985
  parsedCommands: parsedFrontendVerifyCommands.staticCommands,
1809
- adapterCommands: sources.verifyCommands?.intermediate,
1986
+ adapterCommands: adapterVerifyCommands,
1987
+ });
1988
+ const partitionedStaticVerifyCommands = partitionFrontendStaticVerifyCommands({
1989
+ repoRoot: sources.repoRoot,
1990
+ commands: staticVerifyCommands.commands,
1991
+ commandSource: staticVerifyCommands.commandSource,
1810
1992
  });
1811
1993
  const behaviorVerifyCommands = chooseFrontendVerifyCommands({
1994
+ explicitCommands: explicitFrontendVerifyCommands.behaviorCommands,
1812
1995
  parsedCommands: parsedFrontendVerifyCommands.behaviorCommands,
1813
- adapterCommands: sources.verifyCommands?.final,
1996
+ adapterCommands: adapterVerifyCommands,
1814
1997
  });
1815
1998
  const staticShellCommands = buildVerifyShellCommands({
1816
1999
  repoRoot: sources.repoRoot,
1817
- commands: staticVerifyCommands.commands,
2000
+ commands: partitionedStaticVerifyCommands.static.commands,
1818
2001
  fallbackCommands: staticFallbackCommands,
1819
2002
  });
2003
+ const lintShellCommands = buildVerifyShellCommands({
2004
+ repoRoot: sources.repoRoot,
2005
+ commands: partitionedStaticVerifyCommands.lint.commands,
2006
+ fallbackCommands: [],
2007
+ });
1820
2008
  const behaviorShellCommands = buildVerifyShellCommands({
1821
2009
  repoRoot: sources.repoRoot,
1822
2010
  commands: behaviorVerifyCommands.commands,
@@ -1825,16 +2013,28 @@ function buildFrontendHybridDagFromTask(sources) {
1825
2013
  const staticVerifyEvidence = buildVerifyEvidence({
1826
2014
  phase: "intermediate",
1827
2015
  quota: strategy.intermediateQuota ?? "full",
1828
- commandSource: staticVerifyCommands.commandSource,
1829
- commands: staticVerifyCommands.commands,
2016
+ commandSource: partitionedStaticVerifyCommands.static.commandSource,
2017
+ commands: partitionedStaticVerifyCommands.static.commands,
1830
2018
  fallbackCommands: staticFallbackCommands,
2019
+ commandTexts: staticShellCommands,
1831
2020
  });
2021
+ const lintVerifyEvidence = lintShellCommands.length > 0
2022
+ ? buildVerifyEvidence({
2023
+ phase: "intermediate",
2024
+ quota: strategy.intermediateQuota ?? "full",
2025
+ commandSource: partitionedStaticVerifyCommands.lint.commandSource,
2026
+ commands: partitionedStaticVerifyCommands.lint.commands,
2027
+ fallbackCommands: [],
2028
+ commandTexts: lintShellCommands,
2029
+ })
2030
+ : undefined;
1832
2031
  const behaviorVerifyEvidence = buildVerifyEvidence({
1833
2032
  phase: "final",
1834
2033
  quota: "full",
1835
2034
  commandSource: behaviorVerifyCommands.commandSource,
1836
2035
  commands: behaviorVerifyCommands.commands,
1837
2036
  fallbackCommands: behaviorFallbackCommands,
2037
+ commandTexts: behaviorShellCommands,
1838
2038
  finalFullRequired: true,
1839
2039
  });
1840
2040
  const mockVerifyTemplate = mockMode === "required" && hasMockVerifyCommands
@@ -1897,6 +2097,7 @@ function buildFrontendHybridDagFromTask(sources) {
1897
2097
  subtask_prompt: [
1898
2098
  "Inspect frontend code, routing, components, styles, package scripts, and tests.",
1899
2099
  "Return code and design observations, existing reuse opportunities, and verification entry points.",
2100
+ "Derive all file paths from this target workspace. Do not assume the project uses src/, test/, React, or the loop-agent repository layout.",
1900
2101
  "Read-only: do not modify repository files.",
1901
2102
  sourceContext,
1902
2103
  ].join("\n\n"),
@@ -1921,6 +2122,7 @@ function buildFrontendHybridDagFromTask(sources) {
1921
2122
  "Based on frontend-contract-pi, frontend-scout-pi, task sources, and the generation-time Mock capability evidence, return a minimal frontend implementation plan.",
1922
2123
  "Select the Mock / API strategy inside the plan and structured contract. Carry endpoint/fixture mapping, explicit activation, production-default-off rule, verification commands, and Real Integration Gap into both outputs.",
1923
2124
  "Include ordered steps, target files, UI state handling, styling/component strategy, interaction notes, Mock/API strategy, dependency policy, deterministic verification entrypoints, and residual risks. Use only the fixed entrypoints below; implementation may add tests behind them but cannot replace them.",
2125
+ "Every target file and verification target must be selected from the current target workspace and task scope. Do not reuse paths or symbols from examples, prior tasks, or loop-agent itself; if the project uses app/, packages/, spec/, __tests__, or another layout, preserve that layout.",
1924
2126
  "End with exactly one fenced json object conforming to frontend-implementation-contract-v1 so small topology can materialize the contract without plan-revision.",
1925
2127
  requirementCoverageInstruction,
1926
2128
  "Read-only: do not modify code, docs, artifacts, or repository files.",
@@ -2042,16 +2244,47 @@ function buildFrontendHybridDagFromTask(sources) {
2042
2244
  : ["native", "browser-intercept", "request-adapter", "not-needed"],
2043
2245
  artifactName: "frontend-implementation-contract.json",
2044
2246
  outputDir: "contracts",
2247
+ requireSourceFreshness: true,
2248
+ implementationWriteSet: implementPaths.writeSet,
2045
2249
  openspecCandidatePaths: sources.frontendProjectCapability?.designEvidence.normativePaths ?? [],
2046
2250
  },
2047
2251
  cwd: ".",
2048
2252
  timeoutMs: 60000,
2049
2253
  },
2050
2254
  },
2255
+ ...(lintShellCommands.length > 0 && lintVerifyEvidence
2256
+ ? [
2257
+ {
2258
+ id: "frontend-lint-baseline-shell",
2259
+ depends_on: ["frontend-prewrite-gate-shell"],
2260
+ role: "verifier",
2261
+ executor: "shell",
2262
+ complexity: "LOW",
2263
+ writePolicy: "read-only",
2264
+ allowedPaths: readOnlyPaths,
2265
+ forbiddenPaths,
2266
+ outputContract: "Capture writer-preceding lint output as frontend-lint-baseline-v1 without treating existing lint diagnostics as writer failure.",
2267
+ subtask_prompt: "Run the frozen lint commands read-only. Preserve raw output and mark the baseline unavailable on timeout, execution failure, unparseable output, or worktree mutation.",
2268
+ shell: {
2269
+ commands: lintShellCommands,
2270
+ frontendLintBaseline: {
2271
+ schemaVersion: 1,
2272
+ lintCommands: lintShellCommands,
2273
+ lintEvidence: lintVerifyEvidence,
2274
+ },
2275
+ cwd: ".",
2276
+ timeoutMs: 300000,
2277
+ },
2278
+ },
2279
+ ]
2280
+ : []),
2051
2281
  {
2052
2282
  id: implementId,
2053
2283
  depends_on: [
2054
2284
  "frontend-prewrite-gate-shell",
2285
+ ...(lintShellCommands.length > 0
2286
+ ? ["frontend-lint-baseline-shell"]
2287
+ : []),
2055
2288
  "frontend-plan-revision-pi",
2056
2289
  "frontend-final-design-review-pi",
2057
2290
  "frontend-plan-pi",
@@ -2098,11 +2331,17 @@ function buildFrontendHybridDagFromTask(sources) {
2098
2331
  frontendVerificationBundle: {
2099
2332
  schemaVersion: 1,
2100
2333
  mockCommands: mockShellCommands,
2334
+ lintCommands: lintShellCommands,
2101
2335
  staticCommands: staticShellCommands,
2102
2336
  behaviorCommands: behaviorShellCommands,
2103
2337
  mockEvidence: mockVerifyEvidence,
2338
+ lintEvidence: lintVerifyEvidence,
2104
2339
  staticEvidence: staticVerifyEvidence,
2105
2340
  behaviorEvidence: behaviorVerifyEvidence,
2341
+ lintBaselineNodeId: lintShellCommands.length > 0
2342
+ ? "frontend-lint-baseline-shell"
2343
+ : undefined,
2344
+ writerNodeIds: lintShellCommands.length > 0 ? [implementId] : [],
2106
2345
  mode: "initial",
2107
2346
  },
2108
2347
  cwd: ".",
@@ -2151,11 +2390,19 @@ function buildFrontendHybridDagFromTask(sources) {
2151
2390
  frontendVerificationBundle: {
2152
2391
  schemaVersion: 1,
2153
2392
  mockCommands: mockShellCommands,
2393
+ lintCommands: lintShellCommands,
2154
2394
  staticCommands: staticShellCommands,
2155
2395
  behaviorCommands: behaviorShellCommands,
2156
2396
  mockEvidence: mockVerifyEvidence,
2397
+ lintEvidence: lintVerifyEvidence,
2157
2398
  staticEvidence: staticVerifyEvidence,
2158
2399
  behaviorEvidence: behaviorVerifyEvidence,
2400
+ lintBaselineNodeId: lintShellCommands.length > 0
2401
+ ? "frontend-lint-baseline-shell"
2402
+ : undefined,
2403
+ writerNodeIds: lintShellCommands.length > 0
2404
+ ? [implementId, "frontend-repair-pi"]
2405
+ : [],
2159
2406
  mode: "repair",
2160
2407
  },
2161
2408
  cwd: ".",
@@ -2177,11 +2424,11 @@ function buildFrontendHybridDagFromTask(sources) {
2177
2424
  writePolicy: "read-only",
2178
2425
  allowedPaths: readOnlyPaths,
2179
2426
  forbiddenPaths,
2180
- outputContract: "Canonical frontend review context containing validated contract, effective verification trace, repair assessment, and actual worktree diff.",
2427
+ outputContract: "Canonical frontend review context containing validated contract, lint assessment when configured, effective verification trace, repair assessment, and actual worktree diff.",
2181
2428
  subtask_prompt: "Capture the actual diff and bind it to the effective initial-or-post-repair verification evidence for final review.",
2182
2429
  shell: {
2183
2430
  commands: [],
2184
- frontendReviewContext: { schemaVersion: 1 },
2431
+ frontendReviewContext: { schemaVersion: 1, requireBaseline: true },
2185
2432
  cwd: ".",
2186
2433
  timeoutMs: 120000,
2187
2434
  },
@@ -2213,7 +2460,8 @@ function buildFrontendHybridDagFromTask(sources) {
2213
2460
  "Review the frontend implementation and verification evidence.",
2214
2461
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
2215
2462
  "Any Critical or Important finding must force VERDICT: request-revision.",
2216
- "Read contracts/frontend-review-context.json from frontend-review-context-shell. It binds the validated implementation contract, effective initial-or-post-repair verification trace, repair assessment, and the run-owned actual diff (contracts/frontend-worktree-diff.json + artifacts/diff_patch.patch). Do not claim actual diff is missing when those artifacts exist; do not invent a diff from the implementation summary alone. Trace proves command/file/symbol binding only—not semantic correctness.",
2463
+ "Read contracts/frontend-review-context.json from frontend-review-context-shell. It binds the validated implementation contract, frontend lint assessment when lint is configured, effective initial-or-post-repair verification trace, repair assessment, and the run-owned actual diff (contracts/frontend-worktree-diff.json + artifacts/diff_patch.patch). Do not claim actual diff is missing when those artifacts exist; do not invent a diff from the implementation summary alone. Trace proves command/file/symbol binding only—not semantic correctness.",
2464
+ "Treat lint status exactly as passed | baseline-debt | failed | unavailable. baseline-debt may continue only with intact evidence and zero diagnostics on writer-changed files; report the tolerated debt count and never rewrite it as lint passed. Typecheck, build, and test still require successful final exits.",
2217
2465
  "Flag .skip/.only, deleted or weakened tests, unauthorized config changes, Mock-only evidence claimed as real integration, and Browser/visual claims (always not-run in this workflow).",
2218
2466
  "Use the direct contract and the effective plan/design branch: original plan plus initial pass when revision was skipped, or revised plan plus final design review when revision ran. Do not infer them from the implementation summary.",
2219
2467
  "Treat a commented-out real request, default-enabled Mock, production entrypoint importing test mocks, API/fixture contract drift, unauthorized Mock dependency/path, or missing behavior evidence for the selected strategy as at least Important. Mock strategies require Mock-backed evidence. not-needed requires applicable real/no-remote behavior evidence unless auto mode explicitly skipped Mock because no project Mock capability exists; in that case verify that the real request remains the default and the Real Integration Gap is preserved.",
@@ -2271,7 +2519,7 @@ function buildFrontendHybridDagFromTask(sources) {
2271
2519
  outputContract: "Markdown closeout summary with Changes, Mock Decision / Strategy / Files / Verification / Production Boundary, Verification Evidence, Review Result, Frontend Status, Real Integration Status, Known Risks, and Follow-up. No file writes.",
2272
2520
  subtask_prompt: [
2273
2521
  "Return a frontend closeout summary covering Mock decision/strategy/files/verification/production boundary, changes, verification evidence, review result, known risks, and follow-up.",
2274
- "Include a coverage matrix for each requirement id, applicable UI state, and verification target/check with status passed|failed|not-run|blocked|unavailable. Always state Browser accessibility verification: not-run and Visual regression: not-run. Use contracts/frontend-review-context.json and the effective frontend-verify-assess-shell or frontend-reverify-shell facts; do not invent Browser evidence from component tests.",
2522
+ "Include a coverage matrix for each requirement id, applicable UI state, and verification target/check with status passed|failed|not-run|blocked|unavailable. Report lint separately as passed|baseline-debt|failed|unavailable; baseline-debt is explicit debt, not passed. Always state Browser accessibility verification: not-run and Visual regression: not-run. Use contracts/frontend-review-context.json and the effective frontend-verify-assess-shell or frontend-reverify-shell facts; do not invent Browser evidence from component tests.",
2275
2523
  `When only Mock-backed evidence passed, state exactly Frontend status: mock-validated and Real integration: pending, summarize the Real Integration Gap, and name ${taskConfig.taskId}-real-api-integration-verify as the explicit follow-up task to create/run after backend readiness. This follow-up is not auto-created or auto-executed. Never describe Mock evidence as real API integration.`,
2276
2524
  `When Mock was skipped in auto mode and no real API evidence passed, state exactly Frontend status: locally-validated and Real integration: pending, summarize the Real Integration Gap, and name ${taskConfig.taskId}-real-api-integration-verify as the explicit follow-up task when backend readiness matters.`,
2277
2525
  "Read-only: do not modify code, docs, artifacts, or .harness/dag-runs/.",
@@ -2801,12 +3049,18 @@ function buildBackendTestSemanticReviewNode(sources, options = {}) {
2801
3049
  }
2802
3050
  function collectBackendTestShellEnvAllowlist(sources) {
2803
3051
  const names = new Set();
2804
- for (const verify of sources.taskConfig.verifyCommands) {
3052
+ const collectAssignments = (text) => {
2805
3053
  const assignmentPattern = /(?:^|[\s;&|])([A-Z_][A-Z0-9_]*)\s*=/g;
2806
- for (const match of verify.command.matchAll(assignmentPattern)) {
3054
+ for (const match of text.matchAll(assignmentPattern)) {
2807
3055
  if (match[1])
2808
3056
  names.add(match[1]);
2809
3057
  }
3058
+ };
3059
+ for (const constraint of sources.taskConfig.hardConstraints) {
3060
+ collectAssignments(constraint);
3061
+ }
3062
+ for (const verify of sources.taskConfig.verifyCommands) {
3063
+ collectAssignments(verify.command);
2810
3064
  }
2811
3065
  return [...names].sort();
2812
3066
  }
@@ -3051,7 +3305,7 @@ async function buildBackendTestHybridDag(sources) {
3051
3305
  "Read the upstream environment report. Generate a Markdown-first backend test strategy and cases under testcase/md/**.",
3052
3306
  "Write human-readable content in Simplified Chinese by default. Keep English only for machine-readable IDs and technical literals such as Case/AC/REQ/BR IDs, HTTP methods, paths, field names, enum values, commands, filenames, code symbols and exact source citations.",
3053
3307
  "Create testcase/md/README.md as the concise entry page: test objective, target/environment, isolation/cleanup, module summary and a linked case index table with Case ID, Chinese case name, scenario type, endpoint and expected status/result. Avoid repeating every case body in README.",
3054
- "Write each module as readable case cards. Every case starts with `## BE-<MODULE>-<NNN>|<中文用例名称>` and uses these Chinese headings: `### 测试目的`, `### 验收标准`, `### 需求依据`, `### 前置条件`, optional `### 测试数据`, `### 操作步骤`, `### 预期结果`, and `### 自动化映射`. API metadata may use a compact table under the case heading. The deterministic validator also accepts legacy English headings, but new output should use this Chinese presentation.",
3308
+ "Write each module as readable case cards. Every case starts with `## BE-<MODULE>-<NNN>|<中文用例名称>`. The only sections required by the deterministic validator are `### 前置条件`, `### 操作步骤`, and `### 预期结果` (legacy English aliases remain accepted). Add `测试目的`, `验收标准`, `需求依据`, `测试数据`, and `自动化映射` when useful for human readability; every automatable case should explicitly name its target pytest script under `自动化映射` so traceability can scan only that script.",
3055
3309
  "Place steps and their expected results in a compact readable table when that improves clarity; otherwise keep numbered executable steps and numbered/bulleted independently assertable results. Every result must name the observable HTTP status, response field/value, state transition or membership condition, never vague wording such as ‘符合预期’.",
3056
3310
  "In `自动化映射`, record the planned script path and pytest function name when known. Put implementation-only restrictions in a concise `<details>` block rather than dominating the main case flow. Use only environment-supported fixtures/targets/isolation, record evidence gaps in Chinese, and do not emit JSON, pytest, or execute commands.",
3057
3311
  intake.boundedSourceContext, "## Authoritative reference index", JSON.stringify(intake.referenceIndex, null, 2),
@@ -3065,7 +3319,7 @@ async function buildBackendTestHybridDag(sources) {
3065
3319
  writeSet: ["testcase/md/**"], allowedPaths: ["testcase/md/**"], forbiddenPaths: forbidden,
3066
3320
  outputContract: "Review source fidelity and directly revise only testcase/md/**; return concise Markdown, never JSON.",
3067
3321
  subtask_prompt: [
3068
- "Independently review generated Markdown cases against each case 需求依据 and environment evidence. Treat the files as human-facing test documentation: require a clear Chinese name and scenario/purpose, compact metadata, readable steps/results, and a concise automation mapping while preserving exact machine IDs and technical literals.",
3322
+ "Independently review generated Markdown cases against the task requirements and environment evidence. Treat the files as human-facing test documentation: require clear preconditions, executable steps and assertable expected results; improve names, purpose, metadata and automation mapping where useful while preserving exact machine IDs and technical literals.",
3069
3323
  "Check AC completeness/meaning, endpoint, fields/shape, status/error codes, rules, states, documented boundaries/auth, positive/negative coverage, executable steps and assertable results. Reject avoidable English prose, duplicated bilingual wording, repeated boilerplate, oversized unstructured sections, vague results such as ‘符合预期’, and missing script/function mapping where it can be derived.",
3070
3324
  "Correct testcase/md/** directly: add documented omissions, remove unsupported cases, fix mappings/expectations, merge duplicates, improve navigation/tables/Chinese wording, or record gaps in Chinese. Keep Case IDs, AC/REQ/BR IDs, HTTP methods, paths, fields, enum values, filenames, code symbols and source citations exact. The validator accepts Chinese and legacy English section aliases; retain or converge to the Chinese human-readable headings without losing structure.",
3071
3325
  "Read only precise referenced source paths plus requirement sections needed for uncovered ACs. Do not scan the repository, modify source/**, generate pytest, execute tests, or emit JSON.",
@@ -3073,7 +3327,7 @@ async function buildBackendTestHybridDag(sources) {
3073
3327
  "For each index entry, use `readPath` for Pi read-tool calls and keep `path` as the exact Markdown Source References citation. Bound files under .harness/tasks/<taskId>/source/** are read-only inputs: reading them is allowed even though writing .harness/** is forbidden. Never resolve `path` relative to the repository root, search for substitutes, or fall back to docs/** when a bound read fails.",
3074
3328
  ].join("\n\n"),
3075
3329
  };
3076
- const validateCases = shellNode("validate-backend-md-cases-shell", [reviewCases.id], "markdown-cases", "Fail closed on missing/duplicate IDs, sections, AC coverage, source references, executable steps, assertable results, placeholders or secret-shaped content.", "Run-owned reports/backend-md-case-validation.md proving final Markdown quality and safety.");
3330
+ const validateCases = shellNode("validate-backend-md-cases-shell", [reviewCases.id], "markdown-cases", "Record advisory findings for missing/duplicate IDs, missing core sections (preconditions, steps, expected results), AC coverage, executable steps, assertable results or placeholders. Do not validate source-reference existence. Keep quality findings advisory, but fail closed after writing the report when secret-shaped values are detected so downstream pytest/report nodes cannot consume them.", "Run-owned reports/backend-md-case-validation.md with PASS/FAIL advisory findings; downstream execution continues.");
3077
3331
  const generatePytest = {
3078
3332
  id: "generate-backend-pytest-pi", depends_on: [validateCases.id], role: "implementer",
3079
3333
  executor: "pi", toolProfile: "write", complexity: "HIGH", writePolicy: "exclusive",
@@ -3081,20 +3335,23 @@ async function buildBackendTestHybridDag(sources) {
3081
3335
  allowedPaths: Array.from(new Set([...ro, "testcase/**"])), forbiddenPaths: forbidden,
3082
3336
  outputContract: "Convert every final automatable Markdown case into pytest assets whose actual test function region contains the exact Case ID, preferably in the function name or docstring; no JSON and no pytest execution.",
3083
3337
  subtask_prompt: [
3084
- "Convert validated testcase/md/** to pytest using upstream environment and validation evidence plus only bounded pytest config/conftest.",
3085
- "Ensure every final Markdown Case ID appears in at least one real pytest test function or pytest test class method region, preferably as `test_BE_<MODULE>_<NNN>_<description>` and in that function/method docstring. Module-level functions and class-based pytest methods are both supported. Multiple test functions may cover one Case ID; assertions come only from 预期结果/Expected Results and setup comes only from 前置条件/测试数据/自动化映射 or their legacy English aliases.",
3338
+ "Convert testcase/md/** to pytest using upstream environment and advisory validation evidence plus only bounded pytest config/conftest. A FAIL advisory report does not authorize inventing missing behavior; use the final Markdown facts that are present.",
3339
+ "Ensure every final Markdown Case ID appears in at least one real pytest test function or pytest test class method region, preferably as `test_BE_<MODULE>_<NNN>_<description>` and in that function/method docstring. Module-level functions and class-based pytest methods are both supported. Multiple test functions may cover one Case ID; assertions come only from 预期结果/Expected Results and setup comes only from 前置条件 plus any optional 测试数据/自动化映射 or their legacy English aliases.",
3340
+ "Generate a reusable HTTP logging helper (or equivalent client wrapper) and call it for every interface request. The request log must include method, URL/path, and request parameters (query plus JSON/body/payload summary). The response log must include status code and response result (JSON/text/body summary), and both records must be visible in pytest stdout/stderr without changing assertions.",
3341
+ "Compare timestamps and other semantically equivalent protocol values by parsed meaning, not byte-for-byte serialization. In particular, normalize valid ISO-8601 instants before equality/order assertions so differences such as omitted trailing fractional seconds do not create TestBug failures; preserve exact-string assertions only when the Markdown explicitly requires representation equality.",
3342
+ "Before logging, recursively redact sensitive keys and header values including authorization, proxy-authorization, cookie, set-cookie, token, password, secret, api key and credentials. Never print full Authorization/Cookie values. Apply bounded truncation to serialized request and response bodies (with an explicit truncation marker) so large payloads cannot flood pytest or report artifacts.",
3086
3343
  "Do not read source/**, add cases, reassign ACs, modify conftest/config/production code, use skip/xfail, swallow assertions, execute pytest, or emit JSON. For best-effort cleanup, catch only the narrow transport exception actually raised by the selected HTTP client (for example `requests.RequestException` or `urllib.error.URLError`); never use bare `except`, `Exception`, or `BaseException` with `pass`.",
3087
3344
  ].join("\n\n"),
3088
3345
  };
3089
- const traceability = shellNode("backend-test-traceability-gate-shell", [generatePytest.id], "markdown-traceability", "Fail closed only when a real Markdown case heading has no associated pytest test function or class method. Accept exact Case IDs in the function/method name or its decorator/body/docstring region; report multiple mappings and extra automation Case IDs without blocking. Continue to reject skip/xfail or swallowed exceptions.", "Run-owned reports/backend-test-traceability.md proving every real Markdown Case ID is covered by at least one pytest test function.");
3346
+ const traceability = shellNode("backend-test-traceability-gate-shell", [generatePytest.id], "markdown-traceability", "Record advisory findings when a real Markdown case heading has no associated pytest test function or class method in the script explicitly mapped by that Markdown case, or when a mapped HTTP test script lacks request parameters logging, response result logging, recursive redaction or bounded truncation evidence. Accept exact Case IDs in the function/method name or its decorator/body/docstring region. Do not scan unrelated test_*.py files and do not block pytest execution.", "Run-owned reports/backend-test-traceability.md with PASS/FAIL advisory findings for Markdown Case to mapped pytest script/symbol coverage.");
3090
3347
  const pytestCommand = [
3091
3348
  'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
3092
- 'PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest testcase/ -v -p no:cacheprovider --junitxml="${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml"',
3093
- "STATUS=$?", 'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
3094
- 'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml" ]; then exit 0; fi',
3095
- 'exit "${STATUS}"',
3349
+ 'echo "pytest targets are resolved at runtime from final Markdown 自动化映射"',
3096
3350
  ].join("; ");
3097
- const execute = shellNode("execute-backend-pytest-and-html-report-shell", [traceability.id], "markdown-execute-html", "Execute pytest exactly once. Validate JUnit, render the primary self-contained Chinese HTML report from the same JUnit plus final Markdown case metadata without rerun, list every case with name/scenario/script/function/result/duration, and preserve failure summaries plus expandable technical details as facts.", "One pytest execution producing valid JUnit, self-contained HTML and reports/backend-test-facts.md; exit 0/1 with valid evidence continues.", [pytestCommand], 300000);
3351
+ const execute = shellNode("execute-backend-pytest-and-html-report-shell", [traceability.id], "markdown-execute-html", "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Validate JUnit, then render the primary self-contained Chinese HTML report from the same JUnit plus final Markdown case metadata without rerun. Keep 测试结论, quality status, failure overview, and a polished per-case result card with concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.", "One scoped pytest execution over Markdown-mapped scripts producing valid JUnit with per-case captured output, self-contained HTML, reports/backend-test.md and reports/backend-test-facts.md; exit 0/1 with valid evidence continues.", [pytestCommand], 300000);
3352
+ if (execute.shell) {
3353
+ execute.shell.envAllowlist = collectBackendTestShellEnvAllowlist(sources);
3354
+ }
3098
3355
  const canWriteReport = taskAllowsBackendTestReportWrite(sources);
3099
3356
  const report = {
3100
3357
  id: "backend-test-report-and-l5-pi", depends_on: [execute.id], role: "closeout", executor: "pi", complexity: "MED",
@@ -3104,7 +3361,9 @@ async function buildBackendTestHybridDag(sources) {
3104
3361
  forbiddenPaths: forbidden,
3105
3362
  outputContract: canWriteReport ? "Final Markdown report and L-5 conclusion under docs/test-reports/**; no JSON." : "Final Markdown report and L-5 conclusion in assistant output; no JSON or writes.",
3106
3363
  subtask_prompt: [
3107
- "Generate the final Markdown report from upstream facts and run-owned environment, case-validation, traceability, JUnit and HTML evidence. Do not emit JSON.",
3364
+ "Generate the final Markdown report from upstream facts and run-owned environment, advisory case-validation, advisory traceability, JUnit and HTML evidence. Do not emit JSON.",
3365
+ "Use this exact human-facing section order: 测试结论 → 执行概览 → 质量校验 → 失败分析 → 风险与建议 → 证据与 L-5. Put the decision and key numbers first, use compact tables/bullets, and keep headings concise. Do not paste entire upstream reports, duplicate per-case tables already present in facts, or repeat the same evidence in multiple sections; link to paths/hashes and quote only the findings needed for the conclusion.",
3366
+ "Always state the exact PASS/FAIL status and findings from nodes 4 and 6. Their FAIL status does not block pytest, but it must remain visible as a quality/traceability risk and must never be rewritten as PASS.",
3108
3367
  "Include environment, case quality/review, automation mapping, exact pytest facts, failure classification/analysis, risks, regression recommendations, evidence paths/hashes, coverage/stability availability, and L-5 READY/NOT READY.",
3109
3368
  "Never override Shell/JUnit facts. One run cannot prove FlakyTest. Missing coverage/stability is Unavailable. L-5 requires pass=100%, AC=100%, automation>=90%, stability>=95% n>=5, line>=80%, branch>=70%, skipped=0 and no blocking Critical risk.",
3110
3369
  canWriteReport ? "Write only under docs/test-reports/**." : "Keep the full report in assistant output.",
@@ -3118,9 +3377,9 @@ async function buildBackendTestHybridDag(sources) {
3118
3377
  successCriteria: extractSuccessCriteria(sources.requirementMarkdown, sources.taskId),
3119
3378
  globalConstraints: [
3120
3379
  ...taskConfig.hardConstraints, ...STANDARD_GLOBAL_CONSTRAINTS,
3121
- "backend-test-dag uses exactly 8 real top-level tasks and executes pytest exactly once.",
3380
+ "backend-test-dag uses exactly 8 real top-level tasks and executes pytest exactly once over only the safe scripts explicitly mapped by final Markdown cases.",
3122
3381
  "Model nodes produce Markdown and pytest assets, never backend-test business JSON envelopes.",
3123
- "Environment, Markdown validation, traceability, JUnit, HTML and execution facts are deterministic fail-closed evidence.",
3382
+ "Environment, advisory Markdown validation, advisory traceability, JUnit, HTML and execution facts are deterministic evidence. Nodes 4 and 6 record findings without blocking nodes 5, 7 or 8.",
3124
3383
  "Only Markdown case generation/review may read source facts; pytest generation must not read source/**.",
3125
3384
  "Functional case IDs use BE-<MODULE>-<NNN>; production code/config, skip/xfail, repair and rerun are forbidden.",
3126
3385
  ],
@@ -3164,51 +3423,52 @@ function buildFrontendTestHybridDag(sources) {
3164
3423
  const evidenceRoot = "testcase/frontend/evidence";
3165
3424
  const declaredAcIdsLiteral = JSON.stringify(declaredAcIds);
3166
3425
  const maxCasesPerBatchLiteral = String(config.maxCasesPerBatch);
3426
+ const checklistScript = [
3427
+ "const fs=require('fs'),path=require('path');",
3428
+ "const root='testcase/frontend/cases';",
3429
+ "const draft=path.join(root,'manifest.draft.json');",
3430
+ "const final=path.join(root,'manifest.json');",
3431
+ "const manifestPath=fs.existsSync(draft)?draft:(fs.existsSync(final)?final:null);",
3432
+ "if(!manifestPath)throw new Error('checklist: missing manifest.draft.json or manifest.json');",
3433
+ "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
3434
+ "if(!Array.isArray(manifest.cases)||manifest.cases.length===0)throw new Error('checklist: empty cases');",
3435
+ `const declaredAc=new Set(${declaredAcIdsLiteral});`,
3436
+ "const issues=[];",
3437
+ "const openRe=/playwright-cli\\s+open\\s+--browser=chrome\\s+--headed\\s+https?:\\/\\/\\S+/i;",
3438
+ "const prodRe=/(?:^|\\/\\/)(?:www\\.)?[^\\s\\/]*(?:prod|production)/i;",
3439
+ "const caseIdRe=/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;", ,
3440
+ "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
3441
+ "for(const c of manifest.cases){",
3442
+ " const id=c&&c.caseId||'?';",
3443
+ " if(typeof c.caseId!=='string'||!caseIdRe.test(c.caseId))issues.push({ruleId:'case-id-shape',caseId:id,detail:'caseId must be FE-<FEATURE>-<NNN>-<dimension>, never AC-FE-*'});",
3444
+ " if(typeof c.caseId==='string'&&/^AC-/i.test(c.caseId))issues.push({ruleId:'case-id-is-ac',caseId:id,detail:'do not use acceptance id as caseId; put AC-FE-* only in acIds'});",
3445
+ " const casePath=typeof c.casePath==='string'?c.casePath:null;",
3446
+ " if(!casePath||!fs.existsSync(casePath)){issues.push({ruleId:'case-file-missing',caseId:id,detail:String(casePath)});continue;}",
3447
+ " if(typeof c.caseId==='string'&&casePath!=='testcase/frontend/cases/'+c.caseId+'.md')issues.push({ruleId:'case-path-mismatch',caseId:id,detail:casePath+' must equal testcase/frontend/cases/'+c.caseId+'.md'});",
3448
+ " const body=fs.readFileSync(casePath,'utf8');",
3449
+ " if(!openRe.test(body))issues.push({ruleId:'open-prefix',caseId:id,detail:'missing playwright-cli open --browser=chrome --headed <absolute-url>; playwright-cli is strongly recommended for browser execution'});", ,
3450
+ " const m=body.match(/playwright-cli\\s+open\\s+--browser=chrome\\s+--headed\\s+(https?:\\/\\/\\S+)/i);",
3451
+ " if(m){const url=m[1].replace(/[)\\]},.\"']+$/,''); if(prodRe.test(url))issues.push({ruleId:'production-url',caseId:id,detail:url});}",
3452
+ " if(!Array.isArray(c.acIds)||c.acIds.length===0)issues.push({ruleId:'ac-mapping',caseId:id,detail:'acIds required (AC-FE-* acceptance ids, not caseId)'});",
3453
+ " else {",
3454
+ " for(const ac of c.acIds){",
3455
+ " if(typeof ac!=='string'){issues.push({ruleId:'ac-id-shape',caseId:id,detail:String(ac)+' must look like AC-FE-001'});continue;}",
3456
+ " if(/^FE-/i.test(ac)){issues.push({ruleId:'ac-id-is-case',caseId:id,detail:ac+' looks like caseId; acIds must be AC-*'});continue;}",
3457
+ " if(!acIdRe.test(ac)){issues.push({ruleId:'ac-id-shape',caseId:id,detail:ac+' must look like AC-FE-001'});continue;}",
3458
+ " if(declaredAc.size>0&&!declaredAc.has(ac))issues.push({ruleId:'unknown-ac',caseId:id,detail:ac+' not in task sourceBinding.requirementIds'});",
3459
+ " }",
3460
+ " }",
3461
+ "}",
3462
+ "if(issues.length){console.error('frontend-test checklist blocked: '+JSON.stringify(issues)); process.exit(1);}",
3463
+ "console.log('frontend-test checklist ok cases='+manifest.cases.length+' source='+path.basename(manifestPath));",
3464
+ ].join("");
3465
+ // Pass generated JavaScript as base64 rather than embedding it inside a
3466
+ // shell-quoted `node -e` argument. Git Bash otherwise consumes backslashes
3467
+ // such as \\s/\\d and interprets Markdown backticks before Node sees them.
3468
+ const checklistScriptBase64 = Buffer.from(checklistScript, "utf8").toString("base64");
3167
3469
  const checklistValidation = [
3168
- "node -e",
3169
- JSON.stringify([
3170
- "const fs=require('fs'),path=require('path');",
3171
- "const root='testcase/frontend/cases';",
3172
- "const draft=path.join(root,'manifest.draft.json');",
3173
- "const final=path.join(root,'manifest.json');",
3174
- "const manifestPath=fs.existsSync(draft)?draft:(fs.existsSync(final)?final:null);",
3175
- "if(!manifestPath)throw new Error('checklist: missing manifest.draft.json or manifest.json');",
3176
- "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
3177
- "if(!Array.isArray(manifest.cases)||manifest.cases.length===0)throw new Error('checklist: empty cases');",
3178
- `const declaredAc=new Set(${declaredAcIdsLiteral});`,
3179
- "const issues=[];",
3180
- "const openRe=/playwright-cli\\s+open\\s+--browser=chrome\\s+--headed\\s+https?:\\/\\/\\S+/i;",
3181
- "const prodRe=/(?:^|\\/\\/)(?:www\\.)?[^\\s\\/]*(?:prod|production)/i;",
3182
- "const codeRe=/\\b(pytest|playwright\\.test|@playwright\\/test)\\b/i;",
3183
- "const barePwRe=/(?:^|[\\s\"'(])(?:npx\\s+playwright\\b|playwright\\s+test\\b|from\\s+['\"]@playwright\\/|require\\(['\"]@playwright\\/|import\\s+.*@playwright\\/|(?<![\\w-])playwright(?!-cli)\\b)/i;",
3184
- "const caseIdRe=/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;",
3185
- "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
3186
- "for(const c of manifest.cases){",
3187
- " const id=c&&c.caseId||'?';",
3188
- " if(typeof c.caseId!=='string'||!caseIdRe.test(c.caseId))issues.push({ruleId:'case-id-shape',caseId:id,detail:'caseId must be FE-<FEATURE>-<NNN>-<dimension>, never AC-FE-*'});",
3189
- " if(typeof c.caseId==='string'&&/^AC-/i.test(c.caseId))issues.push({ruleId:'case-id-is-ac',caseId:id,detail:'do not use acceptance id as caseId; put AC-FE-* only in acIds'});",
3190
- " const casePath=typeof c.casePath==='string'?c.casePath:null;",
3191
- " if(!casePath||!fs.existsSync(casePath)){issues.push({ruleId:'case-file-missing',caseId:id,detail:String(casePath)});continue;}",
3192
- " if(typeof c.caseId==='string'&&casePath!=='testcase/frontend/cases/'+c.caseId+'.md')issues.push({ruleId:'case-path-mismatch',caseId:id,detail:casePath+' must equal testcase/frontend/cases/'+c.caseId+'.md'});",
3193
- " const body=fs.readFileSync(casePath,'utf8');",
3194
- " if(!openRe.test(body))issues.push({ruleId:'open-prefix',caseId:id,detail:'missing playwright-cli open --browser=chrome --headed <absolute-url>'});",
3195
- " const m=body.match(/playwright-cli\\s+open\\s+--browser=chrome\\s+--headed\\s+(https?:\\/\\/\\S+)/i);",
3196
- " if(m){const url=m[1].replace(/[)\\]},.\"']+$/,''); if(prodRe.test(url))issues.push({ruleId:'production-url',caseId:id,detail:url});}",
3197
- " if(codeRe.test(body))issues.push({ruleId:'no-test-source',caseId:id,detail:'pytest/playwright test source forbidden'});",
3198
- " if(barePwRe.test(body))issues.push({ruleId:'playwright-cli-only',caseId:id,detail:'only playwright-cli skill commands allowed; bare Playwright CLI/API/test runner forbidden'});",
3199
- " if(!Array.isArray(c.acIds)||c.acIds.length===0)issues.push({ruleId:'ac-mapping',caseId:id,detail:'acIds required (AC-FE-* acceptance ids, not caseId)'});",
3200
- " else {",
3201
- " for(const ac of c.acIds){",
3202
- " if(typeof ac!=='string'){issues.push({ruleId:'ac-id-shape',caseId:id,detail:String(ac)+' must look like AC-FE-001'});continue;}",
3203
- " if(/^FE-/i.test(ac)){issues.push({ruleId:'ac-id-is-case',caseId:id,detail:ac+' looks like caseId; acIds must be AC-*'});continue;}",
3204
- " if(!acIdRe.test(ac)){issues.push({ruleId:'ac-id-shape',caseId:id,detail:ac+' must look like AC-FE-001'});continue;}",
3205
- " if(declaredAc.size>0&&!declaredAc.has(ac))issues.push({ruleId:'unknown-ac',caseId:id,detail:ac+' not in task sourceBinding.requirementIds'});",
3206
- " }",
3207
- " }",
3208
- "}",
3209
- "if(issues.length){console.error('frontend-test checklist blocked: '+JSON.stringify(issues)); process.exit(1);}",
3210
- "console.log('frontend-test checklist ok cases='+manifest.cases.length+' source='+path.basename(manifestPath));",
3211
- ].join("")),
3470
+ "node -e \"eval(Buffer.from(process.argv[1],'base64').toString('utf8'))\"",
3471
+ checklistScriptBase64,
3212
3472
  ].join(" ");
3213
3473
  const manifestValidation = [
3214
3474
  "node -e",
@@ -3249,6 +3509,23 @@ function buildFrontendTestHybridDag(sources) {
3249
3509
  // Heal malformed/missing case evidence to blocked; only unsafe evidenceDir hard-fails.
3250
3510
  const evidenceValidation = buildFrontendCaseEvidenceValidateShellSnippet();
3251
3511
  const frontendTestOutcomeGate = buildFrontendTestOutcomeGateShellSnippet();
3512
+ const frontendCaseQualityAdvisory = [
3513
+ "node -e",
3514
+ JSON.stringify([
3515
+ "const fs=require('fs'),path=require('path');",
3516
+ "const runDir=process.env.HARNESS_DAG_RUN_DIR; if(!runDir)throw new Error('missing HARNESS_DAG_RUN_DIR');",
3517
+ "const resultPath=path.join(runDir,'contracts','frontend-test-result.json'); if(!fs.existsSync(resultPath))throw new Error('missing '+resultPath);",
3518
+ "const r=JSON.parse(fs.readFileSync(resultPath,'utf8')); const findings=Array.isArray(r.advisoryFindings)?r.advisoryFindings:[];",
3519
+ "const reports='testcase/frontend/reports'; fs.mkdirSync(reports,{recursive:true});",
3520
+ "const esc=v=>String(v??'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('\\\"','&quot;');",
3521
+ "const labels={cases:'用例总数',passed:'通过',failed:'失败',blocked:'阻塞'}; const totals=r.totals||{}; const missing=r.acceptanceCoverage&&Array.isArray(r.acceptanceCoverage.missing)?r.acceptanceCoverage.missing:[];",
3522
+ "const md=['# 前端测试质量建议报告','','> Advisory:以下 finding 用于改进测试资产和证据质量,不阻塞后续流程。','','## 执行摘要','',...Object.entries(labels).map(([k,v])=>'- '+v+': '+Number(totals[k]||0)),'- 执行结果: '+String(r.outcome||'unknown'),'- 缺失 AC: '+(missing.join(', ')||'无'),'','## 建议项','',...(findings.length?findings.map(f=>'- ['+f.ruleId+']'+(f.caseId?' '+f.caseId:'')+': '+f.detail):['- 未发现建议项。']),''];",
3523
+ "fs.writeFileSync(path.join(reports,'frontend-test-case-quality-advisory.md'),md.join('\\n'));",
3524
+ "const rows=findings.length?findings.map(f=>'<tr><td><code>'+esc(f.ruleId)+'</code></td><td>'+esc(f.caseId||'-')+'</td><td>'+esc(f.detail)+'</td></tr>').join(''):'<tr><td colspan=3>未发现建议项。</td></tr>';",
3525
+ "const html='<!doctype html><html lang=\\\"zh-CN\\\"><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1\\\"><title>前端测试质量建议报告</title><style>body{font:16px system-ui,Microsoft YaHei,sans-serif;background:#f5f7fb;color:#172033;margin:0;padding:32px}main{max-width:1100px;margin:auto;background:#fff;padding:32px;border-radius:16px}.badge{display:inline-block;padding:6px 10px;border-radius:999px;background:#fff3cd;color:#946200}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.card{padding:16px;background:#f8fafc;border-radius:10px}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid #e5e7eb}code{color:#475467}@media(max-width:700px){.grid{grid-template-columns:1fr 1fr}}</style></head><body><main><h1>前端测试质量建议报告</h1><p><span class=\\\"badge\\\">Advisory,不阻塞后续流程</span></p><h2>执行摘要</h2><div class=\\\"grid\\\">'+Object.entries(labels).map(([k,v])=>'<div class=\\\"card\\\"><strong>'+v+'</strong><div>'+Number(totals[k]||0)+'</div></div>').join('')+'</div><p>执行结果:'+esc(r.outcome||'unknown')+';缺失 AC:'+esc(missing.join(', ')||'无')+'</p><h2>建议项</h2><table><thead><tr><th>规则</th><th>Case ID</th><th>说明与建议</th></tr></thead><tbody>'+rows+'</tbody></table></main></body></html>';",
3526
+ "fs.writeFileSync(path.join(reports,'frontend-test-case-quality-advisory.html'),html); console.log('frontend-test advisory report findings='+findings.length);",
3527
+ ].join("")),
3528
+ ].join(" ");
3252
3529
  const tasks = [
3253
3530
  {
3254
3531
  id: "retrieve-frontend-test-context-pi",
@@ -3402,8 +3679,8 @@ function buildFrontendTestHybridDag(sources) {
3402
3679
  writePolicy: "read-only",
3403
3680
  allowedPaths: [...ragWriteSet, ...casesWriteSet],
3404
3681
  forbiddenPaths: forbidden,
3405
- outputContract: "Mechanical checklist: open-prefix, non-prod absolute URL, acIds, playwright-cli-only, no pytest/playwright test source; emit structured ruleId issues on failure.",
3406
- subtask_prompt: "Scan generated cases/manifest against the shared blocking checklist. Do not use free-form LLM verdicts.",
3682
+ outputContract: "Mechanical checklist: manifest/case paths, Case ID, non-production playwright-cli open prefix, and AC mapping; alternative executable tool commands are not inspected or blocked.",
3683
+ subtask_prompt: "Scan generated cases/manifest for structural and safety rules only. Strongly recommend playwright-cli for browser execution, but do not inspect or reject alternative executable tool commands and do not use free-form LLM verdicts.",
3407
3684
  shell: { commands: [checklistValidation], cwd: ".", timeoutMs: 120000 },
3408
3685
  }, {
3409
3686
  id: "materialize-frontend-case-manifest-shell",
@@ -3537,6 +3814,20 @@ function buildFrontendTestHybridDag(sources) {
3537
3814
  outputContract: "Write frontend-test retrospective under testcase/frontend/reports/frontend-test-retrospect-<date>.md with coverage, pass/fail/blocked, execution evidence review, risks, findings, and A/B/C/D rating — even when outcome is failed/incomplete. Pipeline acceptance = this report exists (not case 100% pass).",
3538
3815
  subtask_prompt: "Write the frontend test retrospective under testcase/frontend/reports/ after result materialization (do not wait for outcome=pass). Combine AC→case→browser-evidence review with the closeout report: coverage, passed/failed/blocked (including token-budget-exhausted / executor-auth-unavailable), evidence gaps, browser anomalies, residual risks, and A/B/C/D rating. Passed cases need assertion plus screenshot or equivalent evidence when available; failed/blocked need explicit reasons. Blocked cases never count as passed. Do not replace browser evidence with model conclusions. Do not write docs/**. Pipeline success is report production, not case full green.",
3539
3816
  });
3817
+ tasks.push({
3818
+ id: "frontend-test-quality-report-html-shell",
3819
+ depends_on: ["frontend-test-retrospect-pi"],
3820
+ role: "verifier",
3821
+ executor: "shell",
3822
+ complexity: "LOW",
3823
+ writePolicy: "exclusive",
3824
+ writeSet: ["testcase/frontend/reports/**"],
3825
+ allowedPaths: ["testcase/frontend/**"],
3826
+ forbiddenPaths: forbidden,
3827
+ outputContract: "Write a user-readable HTML advisory report after the final Markdown retrospective; findings never block the workflow.",
3828
+ subtask_prompt: "Generate the frontend case quality advisory Markdown and HTML report after the final test report. Record content findings only and never fail because findings exist.",
3829
+ shell: { commands: [frontendCaseQualityAdvisory], cwd: ".", timeoutMs: 120000 },
3830
+ });
3540
3831
  const globalConstraints = [
3541
3832
  ...sources.taskConfig.hardConstraints,
3542
3833
  ...STANDARD_GLOBAL_CONSTRAINTS,
@@ -4559,7 +4850,7 @@ async function buildHybridDagForTemplate(sources, template) {
4559
4850
  const taskContractBinding = await resolveTaskContractBindingForGenerate(sources);
4560
4851
  let spec;
4561
4852
  if (template === "frontend-implementation") {
4562
- spec = buildFrontendHybridDagFromTask(sources);
4853
+ spec = await buildFrontendHybridDagFromTask(sources);
4563
4854
  }
4564
4855
  else if (template === "frontend-test-dag")
4565
4856
  spec = buildFrontendTestHybridDag(sources);