@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.
- package/CHANGELOG.md +24 -0
- package/dist/application/dag/generate-task-dag.js +33 -0
- package/dist/commands/task-source-prepare.js +6 -0
- package/dist/executors/shell-executor.js +111 -0
- package/dist/executors/shell-presets.js +12 -4
- package/dist/task/config-types.js +6 -0
- package/dist/task/contract/constants.js +1 -0
- package/dist/task/contract/project.js +8 -0
- package/dist/task/contract/schema.js +1 -0
- package/dist/task/frontend-preflight.js +131 -0
- package/dist/task/runtime.js +2 -4
- package/dist/task/source-prepare/build-draft.js +9 -0
- package/dist/task/source-prepare/completeness.js +1 -1
- package/dist/worker/observability/read-model.js +134 -0
- package/dist/worker/observe/static/state.js +61 -0
- package/dist/worker/observe/static/styles.css +8 -0
- package/dist/worker/observe/static/views/dag-graph.js +107 -31
- package/dist/worker/observe/static/views/dag-inspector.js +374 -157
- package/dist/worker/observe/static/views/dag.js +4 -11
- package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
- package/dist/workflows/dag/convergence/controller.js +110 -21
- package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
- package/dist/workflows/dag/frontend-review-context.js +7 -1
- package/dist/workflows/dag/frontend-verification-trace.js +14 -3
- package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
- package/dist/workflows/dag/init-hybrid.js +96 -34
- package/dist/workflows/dag/output-protocol.js +180 -7
- package/dist/workflows/dag/runner.js +141 -52
- package/dist/workflows/dag/types.js +4 -0
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/templates/backend-test-dag.json +100 -8
- package/package.json +1 -1
- 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
|
|
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
|
|
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),
|
|
@@ -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
|
+
}
|
package/dist/task/runtime.js
CHANGED
|
@@ -201,10 +201,8 @@ export function parseTaskConfigForRuntime(rawConfig) {
|
|
|
201
201
|
}
|
|
202
202
|
return parsed;
|
|
203
203
|
}
|
|
204
|
-
export function deriveMaxFixLoops(
|
|
205
|
-
|
|
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",
|