@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.26.2] - 2026-08-02
6
+
7
+ ### 重点更新
8
+
9
+ - 后端测试在执行前新增有界的 pytest 收集修复机制,安全且精准地自动修复生成测试中的不一致问题
10
+ - 全面加固 DAG 审查协议与前端契约边界,提升执行稳定性
11
+ - 修复 Observe 看板轮询刷新时的交互状态保留问题,优化使用体验
12
+
13
+ ### 新增
14
+
15
+ - 后端测试在业务 pytest 执行前新增有界的收集(collection)修复机制:仅对生成的测试目录中可唯一归因的语法、导入、模块或参数化不一致进行最多一次自动修复,且严格禁止修改生产代码或放宽断言
16
+
17
+ ### 改进
18
+
19
+ - 加固 DAG 审查协议与追踪门禁,提升执行稳定性
20
+ - 加固前端 DAG 契约边界
21
+ - 稳定 Linux 环境下的测试收集固件(fixture)
22
+ - 对齐受监督的 DAG 收尾拓扑结构
23
+
24
+ ### 修复
25
+
26
+ - 修复 Observe 看板在轮询刷新时破坏 DAG 图视口 DOM 的问题,确保拖动与滚动状态不被销毁
27
+ - 修复节点检查器在内容未变时重建内容的问题,现支持异步标签页安全恢复滚动位置
28
+
5
29
  ## [0.26.1] - 2026-08-02
6
30
 
7
31
  ### 新增
@@ -12,6 +12,9 @@ import { validateDagUseCase } from "./validate-dag.js";
12
12
  import { runDagUseCase } from "./run-dag.js";
13
13
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
14
14
  import { writeTaskConfig } from "../../infrastructure/harness/task-store.js";
15
+ import { runFrontendPreflight } from "../../task/frontend-preflight.js";
16
+ import { observeTaskContract } from "../../task/contract/observe.js";
17
+ import { adoptTaskContract } from "../../task/contract/adopt.js";
15
18
  const PLACEHOLDER_WRITESET_MARKER = "REPLACE/WITH";
16
19
  function buildValidateInput(repoRoot, dagPath, parsed) {
17
20
  return {
@@ -243,6 +246,36 @@ export async function generateTaskDagUseCase(input) {
243
246
  // before any expensive DAG generation or execution. Empty/consistent
244
247
  // repos stay compatible so the default DAG flow is unblocked.
245
248
  await assertExecPlanIndexConsistent(repoRoot);
249
+ const preflightTaskConfig = await loadTaskConfig(repoRoot, parsed.taskId);
250
+ const frontendScope = await runFrontendPreflight({
251
+ repoRoot,
252
+ taskId: parsed.taskId,
253
+ taskConfig: preflightTaskConfig,
254
+ });
255
+ if (frontendScope) {
256
+ await writeTaskConfig(repoRoot, parsed.taskId, {
257
+ ...preflightTaskConfig,
258
+ allowedPaths: [
259
+ ...new Set([
260
+ frontendScope.entrypoint,
261
+ ...frontendScope.implementationPaths,
262
+ ...frontendScope.testPaths,
263
+ ]),
264
+ ],
265
+ });
266
+ const state = await observeTaskContract({ repoRoot, taskId: parsed.taskId });
267
+ if (!state.ref || !state.observedCanonicalHash) {
268
+ throw new Error("frontend preflight could not reconcile the managed Task Contract");
269
+ }
270
+ await adoptTaskContract({
271
+ repoRoot,
272
+ taskId: parsed.taskId,
273
+ expectedRevision: state.ref.revision,
274
+ expectedObservedHash: state.observedCanonicalHash,
275
+ requestId: `frontend-preflight-${parsed.taskId}-${randomUUID()}`,
276
+ requestPayloadSha256: randomUUID().replaceAll("-", "").padEnd(64, "0"),
277
+ });
278
+ }
246
279
  const candidateResult = await initHybridDagFromTask(repoRoot, parsed.taskId, {
247
280
  outputPath: parsed.outputPath,
248
281
  template: "standard-dag",
@@ -18,6 +18,7 @@ const USAGE = `usage:
18
18
  [--task-kind <TaskKind>]
19
19
  [--feature-id <string>]
20
20
  [--allowed-path <glob>]...
21
+ [--allowed-root <glob>]...
21
22
  [--forbidden-path <glob>]...
22
23
  [--no-default-forbidden-paths]
23
24
  [--invariant <text>]...
@@ -113,6 +114,10 @@ export function parseTaskSourcePrepareArgs(args) {
113
114
  options.allowedPaths = pushList(options.allowedPaths, next());
114
115
  continue;
115
116
  }
117
+ if (token === "--allowed-root") {
118
+ options.allowedRoots = pushList(options.allowedRoots, next());
119
+ continue;
120
+ }
116
121
  if (token === "--forbidden-path") {
117
122
  options.forbiddenPaths = pushList(options.forbiddenPaths, next());
118
123
  continue;
@@ -264,6 +269,7 @@ function buildFlags(options) {
264
269
  taskKind: options.taskKind,
265
270
  featureId: options.featureId,
266
271
  allowedPaths: options.allowedPaths,
272
+ allowedRoots: options.allowedRoots,
267
273
  forbiddenPaths: options.forbiddenPaths,
268
274
  noDefaultForbiddenPaths: options.noDefaultForbiddenPaths,
269
275
  invariants: options.invariants,
@@ -24,6 +24,7 @@ import { materializeBackendTestExecutionContract } from "../workflows/dag/backen
24
24
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
25
25
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
26
26
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
27
+ import { assessBackendPytestCollection, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
27
28
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
28
29
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
29
30
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
@@ -476,6 +477,114 @@ async function executeBackendTestPipeline(input, meta) {
476
477
  };
477
478
  }
478
479
  }
480
+ else if (pipeline === "markdown-collection-assess") {
481
+ const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
482
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
483
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
484
+ const targets = mappedScripts.map(shellQuote).join(" ");
485
+ const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
486
+ const [result] = await executePipelineCommands(input, meta, [command]);
487
+ if (!result)
488
+ throw new Error("backend pytest collection command did not produce a result");
489
+ if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
490
+ return {
491
+ ok: false,
492
+ stdout: result.stdout,
493
+ stderr: result.stderr || "backend pytest collection could not start",
494
+ failureCategory: result.failureCategory,
495
+ durationMs: Date.now() - started,
496
+ };
497
+ }
498
+ const facts = assessBackendPytestCollection({
499
+ phase: "initial",
500
+ mappedScripts,
501
+ inventory,
502
+ exitCode: result.exitCode ?? 2,
503
+ stdout: result.stdout,
504
+ stderr: result.stderr,
505
+ });
506
+ const artifacts = await writeBackendPytestCollectionArtifacts({
507
+ runDir: meta.runDir,
508
+ stem: "initial",
509
+ facts,
510
+ });
511
+ outputs.push(`collectionFacts=${artifacts.factsPath}`, `collectionReport=${artifacts.reportPath}`);
512
+ return {
513
+ ok: true,
514
+ stdout: JSON.stringify({
515
+ status: facts.status,
516
+ repairEligible: facts.repairEligible,
517
+ pytestExitCode: facts.pytestExitCode,
518
+ collectedItemCount: facts.collectedItemCount,
519
+ factsPath: "contracts/backend-test-pytest-collection-initial.json",
520
+ }),
521
+ stderr: "",
522
+ durationMs: Date.now() - started,
523
+ };
524
+ }
525
+ else if (pipeline === "markdown-collection-effective") {
526
+ const initial = await readBackendPytestCollectionFacts(path.join(meta.runDir, "contracts", "backend-test-pytest-collection-initial.json"));
527
+ let effective;
528
+ if (initial.status === "PASS") {
529
+ effective = await materializeEffectiveBackendPytestCollection({
530
+ workspaceRoot: input.cwd,
531
+ initial,
532
+ });
533
+ }
534
+ else if (initial.status === "REPAIRABLE") {
535
+ const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
536
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
537
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
538
+ const targets = mappedScripts.map(shellQuote).join(" ");
539
+ const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
540
+ const [result] = await executePipelineCommands(input, meta, [command]);
541
+ if (!result)
542
+ throw new Error("backend pytest final collection command did not produce a result");
543
+ if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
544
+ return {
545
+ ok: false,
546
+ stdout: result.stdout,
547
+ stderr: result.stderr || "backend pytest final collection could not start",
548
+ failureCategory: result.failureCategory,
549
+ durationMs: Date.now() - started,
550
+ };
551
+ }
552
+ const finalFacts = assessBackendPytestCollection({
553
+ phase: "final",
554
+ mappedScripts,
555
+ inventory,
556
+ exitCode: result.exitCode ?? 2,
557
+ stdout: result.stdout,
558
+ stderr: result.stderr,
559
+ });
560
+ effective = await materializeEffectiveBackendPytestCollection({
561
+ workspaceRoot: input.cwd,
562
+ initial,
563
+ final: finalFacts,
564
+ });
565
+ }
566
+ else {
567
+ throw new Error("backend pytest collection is blocked and does not authorize repair");
568
+ }
569
+ const artifacts = await writeBackendPytestCollectionArtifacts({
570
+ runDir: meta.runDir,
571
+ stem: "effective",
572
+ facts: effective,
573
+ });
574
+ return {
575
+ ok: true,
576
+ stdout: JSON.stringify({
577
+ status: effective.status,
578
+ collectionSource: effective.collectionSource,
579
+ repairAttempt: effective.repairAttempt,
580
+ collectedItemCount: effective.collectedItemCount,
581
+ factsPath: "contracts/backend-test-pytest-collection-effective.json",
582
+ reportPath: artifacts.reportPath,
583
+ }),
584
+ stderr: "",
585
+ durationMs: Date.now() - started,
586
+ };
587
+ }
479
588
  else if (pipeline === "markdown-traceability") {
480
589
  let report;
481
590
  try {
@@ -542,6 +651,8 @@ async function executeBackendTestPipeline(input, meta) {
542
651
  outputs.push(`manifest=${manifestPath}`, `materializationStatus=${manifest.materializationStatus ?? "available"}`, `coverageSummary.explicitAcCount=${summary?.explicitAcCount ?? "unavailable"}`, `coverageSummary.coveredAcCount=${summary?.coveredAcCount ?? "unavailable"}`, `coverageSummary.caseCount=${summary?.caseCount ?? "unavailable"}`, `coverageSummary.generatedCount=${summary?.generatedCount ?? "unavailable"}`, `ruleCoverageSummary.ruleCount=${manifest.ruleCoverageSummary?.ruleCount ?? "unavailable"}`, `correspondenceSummary.exactCorrespondenceCount=${manifest.correspondenceSummary?.exactCorrespondenceCount ?? "unavailable"}`, `correspondenceSummary.primarySymbolCount=${manifest.correspondenceSummary?.primarySymbolCount ?? "unavailable"}`, `correspondenceSummary.testPoints=${manifest.correspondenceSummary?.mappedTestPointCount ?? "unavailable"}/${manifest.correspondenceSummary?.testPointCount ?? "unavailable"}`, `correspondenceSummary.variantTestPointCount=${manifest.correspondenceSummary?.variantTestPointCount ?? "unavailable"}`, `correspondenceSummary.assertionTestPointCount=${manifest.correspondenceSummary?.assertionTestPointCount ?? "unavailable"}`, `correspondenceSummary.crossCuttingTestPointCount=${manifest.correspondenceSummary?.crossCuttingTestPointCount ?? "unavailable"}`, `correspondenceSummary.unclassifiedTestPointCount=${manifest.correspondenceSummary?.unclassifiedTestPointCount ?? "unavailable"}`, `correspondenceSummary.duplicateBindingTestPointCount=${manifest.correspondenceSummary?.duplicateBindingTestPointCount ?? "unavailable"}`);
543
652
  }
544
653
  else if (pipeline === "markdown-execute-html") {
654
+ const effectiveCollection = await readBackendPytestCollectionFacts(path.join(meta.runDir, "contracts", "backend-test-pytest-collection-effective.json"));
655
+ await assertBackendPytestCollectionFresh(input.cwd, effectiveCollection);
545
656
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
546
657
  const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
547
658
  const pytestTargets = mappedScripts.map(shellQuote).join(" ");
@@ -15,7 +15,10 @@ function escapeShellSingleQuoted(value) {
15
15
  /**
16
16
  * Build a single shell command that reads `$HARNESS_DAG_RUN_DIR/<fromNodeId>.json`
17
17
  * (injected by the shell executor from runtime meta), parses a single candidate
18
- * verdict line from `assistantText ?? stdout`, and accepts only exact verdict values.
18
+ * verdict from `assistantText ?? stdout`, and accepts only exact verdict values.
19
+ * Sources:
20
+ * - verdict-line: line-based legacy VERDICT protocol
21
+ * - json-review-verdict: structured review JSON with verdict: pass/request-revision
19
22
  * Modes:
20
23
  * - first-non-empty: first non-empty line
21
24
  * - first-verdict-line: first line matching /^VERDICT:/ after trim and optional whole-line Markdown emphasis normalization
@@ -25,6 +28,7 @@ function escapeShellSingleQuoted(value) {
25
28
  */
26
29
  export function buildVerdictGateShellCommand(gate) {
27
30
  const gateLabel = gate.label ?? `${gate.fromNodeId} verdict`;
31
+ const source = gate.source ?? "verdict-line";
28
32
  const lineMode = gate.lineMode ?? "first-non-empty";
29
33
  const candidateNodeIds = [
30
34
  gate.fromNodeId,
@@ -32,8 +36,10 @@ export function buildVerdictGateShellCommand(gate) {
32
36
  ];
33
37
  const config = {
34
38
  label: gateLabel,
39
+ source,
35
40
  lineMode,
36
41
  accept: gate.accept,
42
+ routingAccept: gate.routingAccept ?? [],
37
43
  candidateNodeIds,
38
44
  };
39
45
  const configLiteral = escapeShellSingleQuoted(JSON.stringify(config));
@@ -48,10 +54,12 @@ export function buildVerdictGateShellCommand(gate) {
48
54
  'if(!file){console.error("missing "+cfg.label+" JSON output (tried: "+cfg.candidateNodeIds.join(", ")+")");process.exit(1);}',
49
55
  'const raw=JSON.parse(fs.readFileSync(file,"utf8"));',
50
56
  'const text=String(raw.assistantText ?? raw.stdout ?? "");',
57
+ 'const extractJson=(value)=>{const text=String(value).trim();const fenced=[...text.matchAll(/```(?:json)?\\s*([\\s\\S]*?)\\s*```/gi)];const source=fenced.length===1?String(fenced[0][1]).trim():text;for(let start=0;start<source.length;start+=1){if(source[start]!=="{")continue;let depth=0,inString=false,escaped=false;for(let i=start;i<source.length;i+=1){const ch=source[i];if(inString){if(escaped){escaped=false;}else if(ch==="\\\\"){escaped=true;}else if(ch===String.fromCharCode(34)){inString=false;}continue;}if(ch===String.fromCharCode(34)){inString=true;continue;}if(ch==="{"){depth+=1;}else if(ch===String.fromCharCode(125)){depth-=1;if(depth===0){const candidate=source.slice(start,i+1);try{const parsed=JSON.parse(candidate);if(parsed&&typeof parsed==="object"&&!Array.isArray(parsed))return candidate;}catch{}break;}}}}return "";};',
58
+ 'let first="";',
59
+ 'if(cfg.source==="json-review-verdict"){const jsonText=extractJson(text);if(!jsonText){console.error(cfg.label+" gate blocked: missing JSON review verdict");process.exit(1);}let parsed;try{parsed=JSON.parse(jsonText);}catch(error){console.error(cfg.label+" gate blocked: invalid JSON review verdict: "+error.message);process.exit(1);}first=String(parsed&&parsed.verdict||"");}',
51
60
  'const normalize=(value)=>{const trimmed=String(value).trim();const emphasized=trimmed.match(/^\\*{1,3}\\s*(VERDICT:[^*]+?)\\s*\\*{1,3}$/);return (emphasized?emphasized[1]:trimmed).trim();};',
52
- 'const lines=text.split(/\\r?\\n/).map(normalize);',
53
- 'const first=cfg.lineMode==="first-verdict-line"? (lines.find((value)=>/^VERDICT:/.test(value))??"") : (lines.find((value)=>value.length>0)??"");',
54
- 'if(!cfg.accept.includes(first)){console.error(cfg.label+" gate blocked: "+(first||"missing VERDICT line"));process.exit(1);}',
61
+ 'if(cfg.source!=="json-review-verdict"){const lines=text.split(/\\r?\\n/).map(normalize);first=cfg.lineMode==="first-verdict-line"? (lines.find((value)=>/^VERDICT:/.test(value))??"") : (lines.find((value)=>value.length>0)??"");}',
62
+ 'const routing=Array.isArray(cfg.routingAccept)?cfg.routingAccept:[];if(!cfg.accept.includes(first)&&!routing.includes(first)){console.error(cfg.label+" gate blocked: "+(first|| (cfg.source==="json-review-verdict"?"missing JSON verdict":"missing VERDICT line")));process.exit(1);}',
55
63
  'process.stdout.write(first+"\\n");',
56
64
  ].join("");
57
65
  return `node -e '${escapeShellSingleQuoted(program)}' '${configLiteral}'`;
@@ -104,6 +104,12 @@ const taskConfigObjectSchema = z.object({
104
104
  referenceMaxFilesPerRepo: z.number().int().positive().optional(),
105
105
  referenceMaxTotalFiles: z.number().int().positive().optional(),
106
106
  allowedPaths: z.array(z.string()).optional().default([]),
107
+ /**
108
+ * Coarse repository roots used only by the frontend preflight scout. Before
109
+ * DAG generation, the scout must resolve these into the narrower
110
+ * `allowedPaths` consumed by writer nodes.
111
+ */
112
+ allowedRoots: z.array(z.string()).optional(),
107
113
  forbiddenPaths: z.array(z.string()).optional().default([]),
108
114
  hardConstraints: z.array(z.string()).optional().default([]),
109
115
  autoCommitAfterVerify: z.boolean().optional().default(true),
@@ -13,6 +13,7 @@ export const MANAGED_TASK_CONFIG_FIELDS = [
13
13
  "taskKind",
14
14
  "featureId",
15
15
  "allowedPaths",
16
+ "allowedRoots",
16
17
  "forbiddenPaths",
17
18
  "hardConstraints",
18
19
  "verifyCommands",
@@ -107,6 +107,11 @@ export function projectConstraintsMarkdown(draft) {
107
107
  lines.push(`- ${item}`);
108
108
  }
109
109
  }
110
+ if (draft.constraints.allowedRoots?.length) {
111
+ lines.push("", "## Allowed roots (preflight only)", "");
112
+ for (const item of draft.constraints.allowedRoots)
113
+ lines.push(`- ${item}`);
114
+ }
110
115
  lines.push("", "## Expected verification", "");
111
116
  if (draft.verification.commands.length === 0) {
112
117
  lines.push("- (none)");
@@ -148,6 +153,9 @@ export function mergeManagedTaskConfigFields(existing, draft) {
148
153
  title: draft.title,
149
154
  taskKind: draft.taskKind,
150
155
  allowedPaths: canonicalizePathArray(draft.constraints.allowedPaths),
156
+ ...(draft.constraints.allowedRoots !== undefined
157
+ ? { allowedRoots: canonicalizePathArray(draft.constraints.allowedRoots) }
158
+ : {}),
151
159
  forbiddenPaths: canonicalizePathArray(draft.constraints.forbiddenPaths),
152
160
  hardConstraints,
153
161
  verifyCommands: draft.verification.commands.map((cmd) => ({
@@ -23,6 +23,7 @@ export const taskContractDraftConstraintsSchema = z
23
23
  .object({
24
24
  invariants: z.array(z.string()),
25
25
  allowedPaths: z.array(z.string()),
26
+ allowedRoots: z.array(z.string()).optional(),
26
27
  forbiddenPaths: z.array(z.string()),
27
28
  protectedUserChanges: z.array(z.string()).optional(),
28
29
  expectedVerification: z.array(z.string()).optional(),
@@ -0,0 +1,131 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { executePiStep } from "../executors/pi-executor.js";
5
+ import { resolveDagPiModelConfig } from "../executors/dag-pi-executor.js";
6
+ import { writeJsonAtomic } from "../infrastructure/harness/atomic-write.js";
7
+ import { pathMatchesPattern } from "../shared/git-progress.js";
8
+ import { loadHarnessManifest } from "../governance/harness.js";
9
+ import { resolveExecutorModelMatrices } from "../executors/model-routing.js";
10
+ const frontendScopeSchema = z.object({
11
+ schemaVersion: z.literal(1),
12
+ entrypoint: z.string().min(1),
13
+ implementationPaths: z.array(z.string().min(1)).min(1),
14
+ testPaths: z.array(z.string().min(1)).min(1),
15
+ dataSource: z.string().min(1),
16
+ });
17
+ function isSafeRepositoryPath(value) {
18
+ return value.length > 0 &&
19
+ !path.isAbsolute(value) &&
20
+ !value.includes("\\") &&
21
+ !value.split("/").includes("..");
22
+ }
23
+ function parseScope(text) {
24
+ const candidates = [];
25
+ for (let start = text.indexOf("{"); start >= 0; start = text.indexOf("{", start + 1)) {
26
+ let depth = 0;
27
+ let quoted = false;
28
+ let escaped = false;
29
+ for (let index = start; index < text.length; index += 1) {
30
+ const char = text[index];
31
+ if (quoted) {
32
+ if (escaped)
33
+ escaped = false;
34
+ else if (char === "\\")
35
+ escaped = true;
36
+ else if (char === '"')
37
+ quoted = false;
38
+ continue;
39
+ }
40
+ if (char === '"')
41
+ quoted = true;
42
+ else if (char === "{")
43
+ depth += 1;
44
+ else if (char === "}" && --depth === 0) {
45
+ try {
46
+ candidates.push(JSON.parse(text.slice(start, index + 1)));
47
+ }
48
+ catch { /* continue */ }
49
+ break;
50
+ }
51
+ }
52
+ }
53
+ if (candidates.length === 0)
54
+ throw new Error("frontend preflight output contains no JSON object");
55
+ const parsed = frontendScopeSchema.parse(candidates.at(-1));
56
+ for (const candidate of [
57
+ parsed.entrypoint,
58
+ ...parsed.implementationPaths,
59
+ ...parsed.testPaths,
60
+ ]) {
61
+ if (!isSafeRepositoryPath(candidate)) {
62
+ throw new Error(`unsafe frontend preflight path: ${candidate}`);
63
+ }
64
+ }
65
+ return parsed;
66
+ }
67
+ function ensureWithinRoots(scope, roots) {
68
+ const candidates = [
69
+ scope.entrypoint,
70
+ ...scope.implementationPaths,
71
+ ...scope.testPaths,
72
+ ];
73
+ const outside = candidates.filter((candidate) => !roots.some((root) => pathMatchesPattern(candidate, root)));
74
+ if (outside.length > 0) {
75
+ throw new Error(`frontend preflight discovered paths outside allowedRoots: ${outside.join(", ")}`);
76
+ }
77
+ }
78
+ export async function runFrontendPreflight(input) {
79
+ if (input.taskConfig.taskKind !== "frontend-implementation" ||
80
+ (input.taskConfig.allowedRoots?.length ?? 0) === 0) {
81
+ return undefined;
82
+ }
83
+ const requirementPath = path.join(input.repoRoot, ".harness", "tasks", input.taskId, "source", "需求.md");
84
+ const requirement = await readFile(requirementPath, "utf8");
85
+ const manifest = await loadHarnessManifest(input.repoRoot);
86
+ const models = resolveExecutorModelMatrices(manifest);
87
+ const model = models.pi.MED;
88
+ const prompt = [
89
+ "You are a read-only frontend preflight scout. Inspect the target repository before its implementation DAG is generated.",
90
+ "Locate the existing UI surface that satisfies the requirement. Do not invent a new page when an existing surface exists.",
91
+ `Allowed roots: ${input.taskConfig.allowedRoots.join(", ")}`,
92
+ "Return exactly one JSON object and no Markdown or prose:",
93
+ '{"schemaVersion":1,"entrypoint":"relative/path","implementationPaths":["relative/glob-or-file"],"testPaths":["relative/glob-or-file"],"dataSource":"existing store/API/module"}',
94
+ "Every path must be repository-relative POSIX, must be inside allowed roots, and implementationPaths/testPaths must be the narrowest directories or files that own the existing behavior.",
95
+ "Requirement:",
96
+ requirement,
97
+ ].join("\n\n");
98
+ const result = await executePiStep({
99
+ attachedFiles: [requirementPath],
100
+ modelConfig: resolveDagPiModelConfig(model),
101
+ prompt,
102
+ repoRoot: input.repoRoot,
103
+ step: "analyze",
104
+ toolNames: ["read", "grep", "find", "ls"],
105
+ userMessage: "Perform the frontend preflight scout and return the required JSON.",
106
+ validateOutput: (assistantText) => {
107
+ try {
108
+ const scope = parseScope(assistantText);
109
+ ensureWithinRoots(scope, input.taskConfig.allowedRoots);
110
+ return [];
111
+ }
112
+ catch (error) {
113
+ return [error instanceof Error ? error.message : String(error)];
114
+ }
115
+ },
116
+ });
117
+ if (!result.ok) {
118
+ throw new Error(`frontend preflight failed: ${result.failureCategory}: ${result.stderr || result.assistantText}`);
119
+ }
120
+ const scope = parseScope(result.assistantText);
121
+ ensureWithinRoots(scope, input.taskConfig.allowedRoots);
122
+ const preflightDir = path.join(input.repoRoot, ".harness", "tasks", input.taskId, "preflight");
123
+ await mkdir(preflightDir, { recursive: true });
124
+ await writeJsonAtomic(path.join(preflightDir, "frontend-scope.json"), {
125
+ ...scope,
126
+ resolvedAt: new Date().toISOString(),
127
+ allowedRoots: input.taskConfig.allowedRoots,
128
+ model,
129
+ });
130
+ return scope;
131
+ }
@@ -201,10 +201,8 @@ export function parseTaskConfigForRuntime(rawConfig) {
201
201
  }
202
202
  return parsed;
203
203
  }
204
- export function deriveMaxFixLoops(complexity) {
205
- if (complexity === 'small')
206
- return 2;
207
- return 3;
204
+ export function deriveMaxFixLoops(_complexity) {
205
+ return 2;
208
206
  }
209
207
  export async function listTaskSourceFiles(repoRoot, taskId) {
210
208
  return listFilesRecursive(getTaskPaths(repoRoot, taskId).sourceDir);
@@ -65,6 +65,13 @@ function resolveAllowedPaths(input) {
65
65
  return n.ok ? n.value : raw.trim();
66
66
  });
67
67
  }
68
+ function resolveAllowedRoots(input) {
69
+ const roots = input.flags.allowedRoots ?? input.baseDraft?.constraints.allowedRoots ?? input.existing?.allowedRoots;
70
+ return roots === undefined ? undefined : (cleanStringList(roots) ?? []).map((raw) => {
71
+ const normalized = normalizePreparePath(raw);
72
+ return normalized.ok ? normalized.value : raw.trim();
73
+ });
74
+ }
68
75
  function resolveVerifyCommands(input) {
69
76
  if (input.flags.verifyCommands !== undefined) {
70
77
  return input.flags.verifyCommands.map((cmd) => ({
@@ -122,6 +129,7 @@ export function buildPrepareDraft(input) {
122
129
  baseDraft,
123
130
  existing: existingTaskConfig,
124
131
  });
132
+ const allowedRoots = resolveAllowedRoots({ flags, baseDraft, existing: existingTaskConfig });
125
133
  // D12: protected defaults always merge unless explicitly disabled.
126
134
  // Explicit --forbidden-path appends; does not replace defaults.
127
135
  const finalForbidden = mergeForbiddenPaths({
@@ -175,6 +183,7 @@ export function buildPrepareDraft(input) {
175
183
  forbiddenPaths: pathAssessment.forbidden.length > 0
176
184
  ? pathAssessment.forbidden
177
185
  : dedupeStable(finalForbidden),
186
+ ...(allowedRoots !== undefined ? { allowedRoots: dedupeStable(allowedRoots) } : {}),
178
187
  },
179
188
  verification: {
180
189
  commands: verifyCommands
@@ -81,7 +81,7 @@ export function listPrepareGaps(input) {
81
81
  });
82
82
  }
83
83
  }
84
- if (draft.constraints.allowedPaths.length === 0) {
84
+ if (draft.constraints.allowedPaths.length === 0 && !(draft.taskKind === "frontend-implementation" && (draft.constraints.allowedRoots?.length ?? 0) > 0)) {
85
85
  gaps.push({
86
86
  code: "EMPTY_ALLOWED_PATHS",
87
87
  level: "blocking",