@tea-agent/loop-agent 0.21.0 → 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.
- package/CHANGELOG.md +46 -0
- package/bin/agent-worker.js +0 -0
- package/dist/adapters/loop-agent.js +52 -0
- package/dist/commands/init.js +97 -0
- package/dist/executors/dag-pi-executor.js +2 -0
- package/dist/executors/shell-executor.js +162 -19
- package/dist/shared/openspec-spec.js +49 -0
- package/dist/worker/observability/read-model.js +21 -1
- package/dist/worker/observe/spec-evidence.js +12 -15
- package/dist/worker/observe/static/dag-helpers.js +22 -0
- package/dist/worker/observe/static/views/dag.js +5 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +37 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
- package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
- package/dist/workflows/dag/frontend-project-capability.js +11 -8
- package/dist/workflows/dag/frontend-repair.js +6 -4
- package/dist/workflows/dag/frontend-review-context.js +67 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
- package/dist/workflows/dag/frontend-verification-trace.js +31 -1
- package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
- package/dist/workflows/dag/init-hybrid.js +344 -64
- package/dist/workflows/dag/types.js +62 -1
- package/docs/templates/agent-dag.schema.json +15 -5
- package/docs/templates/backend-test-dag.json +1 -1
- package/docs/templates/frontend-implementation-contract.schema.json +4 -3
- package/docs/templates/frontend-test-case-checklist.md +6 -2
- package/docs/templates/frontend-test-dag.json +2 -2
- package/package.json +1 -1
- package/skills/frontend-design-review/SKILL.md +12 -10
- package/skills/frontend-design-review/references/review-checklist.md +4 -4
- package/skills/frontend-implementation/SKILL.md +2 -2
- package/skills/frontend-implementation/references/code-standards.md +4 -3
- package/skills/frontend-implementation/references/design-spec.md +19 -14
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/frontend-review/SKILL.md +15 -28
- package/skills/frontend-review/references/review-findings.md +16 -18
- package/skills/frontend-verification/SKILL.md +16 -13
- package/skills/frontend-verification/references/verification-checklist.md +18 -30
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.22.0] - 2026-07-24
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- 前端规范读取范围扩展,新增 ai_workspace 作为并列来源,并独立展示各类规范证据
|
|
10
|
+
- 前端实现 DAG 引入 lint 债务基线机制,精准区分存量与新增代码问题,减少误阻断
|
|
11
|
+
- 前端预写门禁增强,强制校验真实的规范读取事件,避免遗漏关键上下文
|
|
12
|
+
- 后端测试报告结构优化,新增 Markdown 报告并改进 HTML 报告的分层展示
|
|
13
|
+
|
|
14
|
+
### 新增
|
|
15
|
+
|
|
16
|
+
- 前端规范读取将 ai_workspace 目录与 openspec 相关目录作为并列来源,即使缺少 package.json 也能发现本地规范候选
|
|
17
|
+
- Dashboard 规范证据 API 与界面独立展示知识库查询、openspec 检索和成功读取三类证据
|
|
18
|
+
- 后端测试在同目录下新增生成 backend-test.md 报告,便于查阅测试结论与明细
|
|
19
|
+
|
|
20
|
+
### 改进
|
|
21
|
+
|
|
22
|
+
- 前端实现 DAG 允许未修改文件中的已知 lint 诊断作为基线债务继续,但对修改文件的新问题严格阻断
|
|
23
|
+
- 后端测试 HTML 报告采用更清晰的分层展示,新增稳定结论图标与可折叠的质量校验明细,移除了原稳定性指标
|
|
24
|
+
- 前端测试用例的内容完整性与证据检查降级为建议性反馈,不再因个别物料缺失阻断最终复盘
|
|
25
|
+
- 设计文档与研发 DAG 拓扑说明同步更新,澄清了 profile/workflow/type 三套正交概念与当前实现状态
|
|
26
|
+
|
|
27
|
+
### 修复
|
|
28
|
+
|
|
29
|
+
- 修复前端技能指令可能超出预算限制的问题
|
|
30
|
+
- 修复 DAG 未正确注册 DeepSeek Pi 模型提供者的问题
|
|
31
|
+
- 修复前端目标项目验证路由不够健壮的问题
|
|
32
|
+
- 修复后端测试中无法跨环境便携解析 Python 运行时的问题
|
|
33
|
+
|
|
5
34
|
## [0.21.0] - 2026-07-24
|
|
6
35
|
|
|
7
36
|
### 重点更新
|
|
@@ -18,6 +47,9 @@
|
|
|
18
47
|
|
|
19
48
|
- backend-test 第 7 节点现在只执行最终 Markdown `自动化映射` 明确声明且安全校验通过的 pytest 脚本,避免历史或无关 `test_*.py` 混入本轮 JUnit/HTML;空映射、缺失脚本或越界路径会 fail-closed。
|
|
20
49
|
- backend-test HTML 报告升级为更清晰的用例卡片:保留测试结论、质量状态和失败概览,直接展示简洁测试场景、自动化用例名称、结果与耗时,并为通过/失败用例都展示同次执行中脱敏且有界的接口请求参数与响应结果;HTML 不再附带技术证据章节,审计路径与哈希继续保留在 facts/JUnit。
|
|
50
|
+
- frontend-test 第四节点不再扫描或阻断 pytest、原生 Playwright、`npx playwright`、`@playwright/test` 或 Node Playwright API 等可执行工具指令;该节点只保留 manifest、Case/AC/path、非生产 URL 和标准 `playwright-cli open` 前缀等结构与安全门禁,并强烈建议浏览器执行优先使用 `playwright-cli`。
|
|
51
|
+
- frontend-test 用例内容、Case ID、AC 关联与 evidence 完整性检查改为 advisory:缺失或损坏的执行物料不再被自动改写为 blocked,也不阻断最终复盘;finding 汇入结构化结果,并在 Markdown retrospective 后生成中文 HTML 建议报告。路径越界、跨用例 evidence 引用、writeSet、生产环境与凭据安全仍保持硬门禁。
|
|
52
|
+
- frontend-test checklist JavaScript 通过 Base64 参数交给 Node 解码执行,并修正变量声明顺序与重复语句,避免 Git Bash 将 `\s` / `\d`、反引号或引号破坏后再交给 Node。
|
|
21
53
|
- 新初始化项目的 harness 指南同步 adaptive liveness、4h absolute max、stall 状态与 `termination-unconfirmed` 处置;旧项目可由 `init check-update` 识别未修改的受管指南,并通过 `init update --apply-safe` 安全刷新。
|
|
22
54
|
|
|
23
55
|
## [0.20.1] - 2026-07-24
|
|
@@ -63,6 +95,19 @@
|
|
|
63
95
|
|
|
64
96
|
### 新增
|
|
65
97
|
|
|
98
|
+
- 继续收紧前端实现证据门禁:禁止空 verification contract,校验验证目标与 requirement/UI state/授权路径绑定;新版 review context 缺失 writer baseline 时直接阻断,非 `not-needed` Mock 策略必须提供 endpoint 与 consumer 证据。
|
|
99
|
+
- 前端写入前重新校验 DAG 绑定源文件的 SHA-256;Mock endpoint 现在必须声明 fixture 与 consumer,且验证 trace 必须记录成功的 Mock 命令。
|
|
100
|
+
- 前端验证 bundle 现在冻结实际命令文本,并将实现契约 targets 限制在 writer writeSet 内,避免验证证据与可写范围漂移。
|
|
101
|
+
- 同步前端 prewrite/review context 的 DAG JSON Schema,避免生成 DAG 因 freshness、writeSet 或 baseline 字段漂移而被错误拒绝。
|
|
102
|
+
- 前端 prewrite 现在强制校验需求源新鲜度,验证 Schema 也要求声明实际命令文本,减少旧 DAG 和证据配置的隐式降级。
|
|
103
|
+
- 前端实现 DAG 的验证命令与侦察范围改为从目标项目自身的 package scripts、包管理器和允许路径推导,不再假设目标项目沿用 loop-agent 的目录与命令布局。
|
|
104
|
+
|
|
105
|
+
### 改进
|
|
106
|
+
|
|
107
|
+
- 前端实现 DAG 的验证命令与侦察范围改为从目标项目自身的 package scripts、包管理器和允许路径推导,不再假设目标项目沿用 loop-agent 的目录与命令布局。
|
|
108
|
+
- 后端测试用例默认生成中文 README 索引与模块用例卡片,步骤、预期、需求依据和自动化映射更便于测试、研发与评审人员阅读。
|
|
109
|
+
- 后端测试 HTML 主报告现在逐条展示用例编号、中文名称与场景、脚本、测试函数、结果、耗时和失败原因,完整技术详情可折叠查看。
|
|
110
|
+
|
|
66
111
|
- streamline implementation DAG (#61)
|
|
67
112
|
- improve human-readable artifacts
|
|
68
113
|
|
|
@@ -86,6 +131,7 @@
|
|
|
86
131
|
### 改进
|
|
87
132
|
|
|
88
133
|
- `frontend-implementation` 保留独立 contract/scout,并把 Mock 策略与 implementation contract 合入 plan,把写前授权、验证/失败评估、复验和 review context 分别收敛为组合 shell 节点。standard/high-risk 固定 15 个顶层节点、small-risk 13 个;绿色路径执行 11 个节点、7 次 Pi,同时保留唯一 prewrite 写入授权、同 writeSet repair、真实 diff review 与 fail-closed closeout。
|
|
134
|
+
- 加强前端实现节点的证据链:Trace 只绑定本次实际成功执行的验证命令,契约兼容转换不再补入占位文件或默认验证目标,Prewrite 要求上游节点明确完成,review context 校验 artifact 绑定,并以 writer 前工作区快照隔离既有用户改动。
|
|
89
135
|
- 优化操作与观测统一界面的导航与布局,合并确认与运行为「开始运行」,DAG 详情跳转路径更加准确
|
|
90
136
|
|
|
91
137
|
### 修复
|
package/bin/agent-worker.js
CHANGED
|
File without changes
|
|
@@ -63,6 +63,11 @@ function getVerifyCommands(repoRoot, options) {
|
|
|
63
63
|
if (manifestCommands) {
|
|
64
64
|
return applyQuota(manifestCommands, options);
|
|
65
65
|
}
|
|
66
|
+
if (options.taskConfig?.taskKind === 'frontend-implementation') {
|
|
67
|
+
const frontendCommands = getFrontendProjectVerifyCommands(repoRoot, options);
|
|
68
|
+
if (frontendCommands)
|
|
69
|
+
return frontendCommands;
|
|
70
|
+
}
|
|
66
71
|
const quickCommands = [
|
|
67
72
|
{
|
|
68
73
|
args: ['bash', 'scripts/check-repo.sh'],
|
|
@@ -91,6 +96,53 @@ function getVerifyCommands(repoRoot, options) {
|
|
|
91
96
|
: [...quickCommands, ...standardCommands, ...fullCommands];
|
|
92
97
|
return applyQuota(selectedCommands, options);
|
|
93
98
|
}
|
|
99
|
+
function getFrontendProjectVerifyCommands(repoRoot, options) {
|
|
100
|
+
let scripts;
|
|
101
|
+
try {
|
|
102
|
+
const packageJson = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
103
|
+
if (!packageJson.scripts || typeof packageJson.scripts !== 'object')
|
|
104
|
+
return undefined;
|
|
105
|
+
scripts = packageJson.scripts;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
let packageManager = 'npm';
|
|
111
|
+
for (const [lockfile, manager] of [
|
|
112
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
113
|
+
['yarn.lock', 'yarn'],
|
|
114
|
+
['bun.lockb', 'bun'],
|
|
115
|
+
['bun.lock', 'bun'],
|
|
116
|
+
]) {
|
|
117
|
+
try {
|
|
118
|
+
readFileSync(path.join(repoRoot, lockfile));
|
|
119
|
+
packageManager = manager;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Try the next package-manager marker.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const hasScript = (name) => typeof scripts[name] === 'string' && String(scripts[name]).trim().length > 0;
|
|
127
|
+
const commandFor = (name) => ({
|
|
128
|
+
args: name === 'test'
|
|
129
|
+
? [packageManager, 'test']
|
|
130
|
+
: [packageManager, 'run', name],
|
|
131
|
+
cwd: repoRoot,
|
|
132
|
+
label: `${packageManager} ${name === 'test' ? 'test' : `run ${name}`}`,
|
|
133
|
+
});
|
|
134
|
+
const collect = (names) => names.filter(hasScript).map(commandFor);
|
|
135
|
+
const staticCommands = collect(['typecheck', 'check-types', 'lint', 'check', 'build']);
|
|
136
|
+
const behaviorCommands = collect(['test:unit', 'test:frontend', 'test:component', 'test', 'test:e2e', 'e2e']);
|
|
137
|
+
if (staticCommands.length === 0 && behaviorCommands.length === 0)
|
|
138
|
+
return undefined;
|
|
139
|
+
const selected = options.phase === 'final'
|
|
140
|
+
? [...staticCommands, ...behaviorCommands]
|
|
141
|
+
: staticCommands;
|
|
142
|
+
if (selected.length === 0)
|
|
143
|
+
return behaviorCommands;
|
|
144
|
+
return applyQuota(selected, options);
|
|
145
|
+
}
|
|
94
146
|
function applyQuota(commands, options) {
|
|
95
147
|
if (options.phase === 'final' || !options.quota || options.quota === 'full') {
|
|
96
148
|
return commands;
|
package/dist/commands/init.js
CHANGED
|
@@ -522,10 +522,107 @@ has_npm_script() {
|
|
|
522
522
|
node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('package.json','utf8')); process.exit(p.scripts && p.scripts[process.argv[1]] ? 0 : 1)" "\${script}" >/dev/null 2>&1
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
dag_lint_assessment_allows_skip() {
|
|
526
|
+
[[ -n "\${HARNESS_DAG_RUN_DIR:-}" ]] || return 1
|
|
527
|
+
command -v node >/dev/null 2>&1 || return 1
|
|
528
|
+
node - "\${HARNESS_DAG_RUN_DIR}" <<'NODE'
|
|
529
|
+
const fs = require("fs");
|
|
530
|
+
const path = require("path");
|
|
531
|
+
const crypto = require("crypto");
|
|
532
|
+
const runDir = path.resolve(process.argv[2]);
|
|
533
|
+
const assessmentPath = path.join(runDir, "contracts", "frontend-lint-assessment.json");
|
|
534
|
+
const fail = () => process.exit(1);
|
|
535
|
+
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
536
|
+
const readContained = (relative) => {
|
|
537
|
+
if (typeof relative !== "string" || path.isAbsolute(relative)) fail();
|
|
538
|
+
const absolute = path.resolve(runDir, relative);
|
|
539
|
+
if (absolute !== runDir && !absolute.startsWith(runDir + path.sep)) fail();
|
|
540
|
+
return fs.readFileSync(absolute);
|
|
541
|
+
};
|
|
542
|
+
let assessment;
|
|
543
|
+
try {
|
|
544
|
+
assessment = JSON.parse(fs.readFileSync(assessmentPath, "utf8"));
|
|
545
|
+
} catch {
|
|
546
|
+
fail();
|
|
547
|
+
}
|
|
548
|
+
if (
|
|
549
|
+
assessment.schemaVersion !== 1 ||
|
|
550
|
+
assessment.schemaId !== "frontend-lint-assessment-v1" ||
|
|
551
|
+
!["passed", "baseline-debt"].includes(assessment.status) ||
|
|
552
|
+
!assessment.commandIdentity ||
|
|
553
|
+
!Array.isArray(assessment.commandIdentity.commands) ||
|
|
554
|
+
assessment.commandIdentity.commands.length === 0 ||
|
|
555
|
+
!Array.isArray(assessment.writerChangedFiles) ||
|
|
556
|
+
!Array.isArray(assessment.blockingDiagnostics) ||
|
|
557
|
+
assessment.blockingDiagnostics.length !== 0 ||
|
|
558
|
+
!Array.isArray(assessment.blockingReasons) ||
|
|
559
|
+
assessment.blockingReasons.length !== 0
|
|
560
|
+
) fail();
|
|
561
|
+
const commandHash = sha256(JSON.stringify(assessment.commandIdentity.commands));
|
|
562
|
+
if (commandHash !== assessment.commandIdentity.sha256) fail();
|
|
563
|
+
if (!assessment.commandIdentity.commands.every((command) =>
|
|
564
|
+
/(?:^|[\\s'"])npm(?:['"])?\\s+(?:['"])?run(?:['"])?\\s+(?:['"])?lint(?:['"])?(?:\\s|$)/.test(command)
|
|
565
|
+
)) fail();
|
|
566
|
+
if (
|
|
567
|
+
(assessment.status === "passed" && assessment.currentExitCode !== 0) ||
|
|
568
|
+
(assessment.status === "baseline-debt" && assessment.currentExitCode === 0)
|
|
569
|
+
) fail();
|
|
570
|
+
if (!assessment.baselineRef || assessment.baselineRef.nodeId !== "frontend-lint-baseline-shell") fail();
|
|
571
|
+
const baselineRaw = readContained(assessment.baselineRef.path);
|
|
572
|
+
if (sha256(baselineRaw) !== assessment.baselineRef.sha256) fail();
|
|
573
|
+
let baseline;
|
|
574
|
+
try {
|
|
575
|
+
baseline = JSON.parse(baselineRaw);
|
|
576
|
+
} catch {
|
|
577
|
+
fail();
|
|
578
|
+
}
|
|
579
|
+
if (
|
|
580
|
+
baseline.schemaVersion !== 1 ||
|
|
581
|
+
baseline.schemaId !== "frontend-lint-baseline-v1" ||
|
|
582
|
+
baseline.status !== "available" ||
|
|
583
|
+
baseline.commandIdentity?.sha256 !== assessment.commandIdentity.sha256
|
|
584
|
+
) fail();
|
|
585
|
+
if (assessment.status === "baseline-debt") {
|
|
586
|
+
if (
|
|
587
|
+
!Array.isArray(assessment.currentDiagnostics) ||
|
|
588
|
+
assessment.currentDiagnostics.length === 0 ||
|
|
589
|
+
assessment.currentDiagnostics.length !== assessment.toleratedDiagnosticCount ||
|
|
590
|
+
!Array.isArray(baseline.diagnostics)
|
|
591
|
+
) fail();
|
|
592
|
+
const key = (item) => JSON.stringify([
|
|
593
|
+
item.file, item.line, item.column, item.severity, item.message, item.ruleId ?? null,
|
|
594
|
+
]);
|
|
595
|
+
const baselineCounts = new Map();
|
|
596
|
+
for (const item of baseline.diagnostics) {
|
|
597
|
+
const value = key(item);
|
|
598
|
+
baselineCounts.set(value, (baselineCounts.get(value) || 0) + 1);
|
|
599
|
+
}
|
|
600
|
+
const changed = new Set(assessment.writerChangedFiles);
|
|
601
|
+
for (const item of assessment.currentDiagnostics) {
|
|
602
|
+
if (changed.has(item.file)) fail();
|
|
603
|
+
const value = key(item);
|
|
604
|
+
const count = baselineCounts.get(value) || 0;
|
|
605
|
+
if (count === 0) fail();
|
|
606
|
+
baselineCounts.set(value, count - 1);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (!Array.isArray(assessment.rawEvidenceRefs) || assessment.rawEvidenceRefs.length === 0) fail();
|
|
610
|
+
for (const ref of assessment.rawEvidenceRefs) {
|
|
611
|
+
if (sha256(readContained(ref.path)) !== ref.sha256) fail();
|
|
612
|
+
}
|
|
613
|
+
process.exit(0);
|
|
614
|
+
NODE
|
|
615
|
+
}
|
|
616
|
+
|
|
525
617
|
if [[ -f package.json ]]; then
|
|
526
618
|
if command -v npm >/dev/null 2>&1; then
|
|
527
619
|
for script in lint typecheck test build; do
|
|
528
620
|
if has_npm_script "\${script}"; then
|
|
621
|
+
if [[ "\${script}" == "lint" ]] && dag_lint_assessment_allows_skip; then
|
|
622
|
+
echo "==> lint handled by frontend DAG assessment (status: passed or baseline-debt)"
|
|
623
|
+
ran=1
|
|
624
|
+
continue
|
|
625
|
+
fi
|
|
529
626
|
run_cmd npm run "\${script}"
|
|
530
627
|
fi
|
|
531
628
|
done
|
|
@@ -19,6 +19,8 @@ export const DAG_PI_MODEL_PROVIDERS = {
|
|
|
19
19
|
"gpt-5.3-codex-spark": "wizard-local",
|
|
20
20
|
"gpt-5.5": "wizard-local",
|
|
21
21
|
"glm-5.2": "wizard-local",
|
|
22
|
+
"deepseek-v4-flash": "deepseek",
|
|
23
|
+
"deepseek-v4-pro": "deepseek",
|
|
22
24
|
};
|
|
23
25
|
const SAFE_PI_STEPS = new Set([
|
|
24
26
|
"analyze",
|
|
@@ -14,6 +14,7 @@ import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "
|
|
|
14
14
|
import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
|
|
15
15
|
import { formatFrontendPrewriteGateStdout, runFrontendPrewriteGate, } from "../workflows/dag/frontend-prewrite-gate.js";
|
|
16
16
|
import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from "../workflows/dag/frontend-review-context.js";
|
|
17
|
+
import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
|
|
17
18
|
import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
|
|
18
19
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
19
20
|
import { materializeBackendTestResultFromRunDir, parseJunitXml } from "../workflows/dag/backend-test-result-contract.js";
|
|
@@ -381,7 +382,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
381
382
|
const pytestTargets = mappedScripts.map(shellQuote).join(" ");
|
|
382
383
|
const pytestCommand = [
|
|
383
384
|
'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
|
|
384
|
-
|
|
385
|
+
'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
|
|
386
|
+
'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" >&2; exit 127; fi',
|
|
387
|
+
`PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 "\${PYTHON_BIN}" -m pytest ${pytestTargets} -v -p no:cacheprovider -o junit_logging=all -o junit_log_passing_tests=true --junitxml="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml"`,
|
|
385
388
|
"STATUS=$?",
|
|
386
389
|
'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
|
|
387
390
|
'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml" ]; then exit 0; fi',
|
|
@@ -411,9 +414,10 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
411
414
|
});
|
|
412
415
|
const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
|
|
413
416
|
const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent, caseValidationSummary, traceabilitySummary });
|
|
417
|
+
const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
|
|
414
418
|
const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
|
|
415
419
|
const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
|
|
416
|
-
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `facts=${factsPath}`, facts);
|
|
420
|
+
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, facts);
|
|
417
421
|
}
|
|
418
422
|
else if (pipeline === "contracts") {
|
|
419
423
|
const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
|
|
@@ -661,13 +665,46 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
661
665
|
catch {
|
|
662
666
|
beforeStatus = undefined;
|
|
663
667
|
}
|
|
668
|
+
const lintResults = [];
|
|
669
|
+
for (const command of bundle.lintCommands ?? []) {
|
|
670
|
+
const commandNumber = results.length + 1;
|
|
671
|
+
const result = await executeShellCommand({
|
|
672
|
+
command,
|
|
673
|
+
cwd,
|
|
674
|
+
timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
|
|
675
|
+
envAllowlist: shell.envAllowlist,
|
|
676
|
+
dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
|
|
677
|
+
outputArtifacts: {
|
|
678
|
+
stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
|
|
679
|
+
stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
lintResults.push(result);
|
|
683
|
+
results.push(result);
|
|
684
|
+
}
|
|
685
|
+
let lintAssessment;
|
|
686
|
+
if ((bundle.lintCommands?.length ?? 0) > 0 &&
|
|
687
|
+
bundle.lintBaselineNodeId &&
|
|
688
|
+
(bundle.writerNodeIds?.length ?? 0) > 0) {
|
|
689
|
+
lintAssessment = await materializeFrontendLintAssessment({
|
|
690
|
+
runDir: meta.runDir,
|
|
691
|
+
workspaceRoot: input.cwd,
|
|
692
|
+
commands: bundle.lintCommands,
|
|
693
|
+
results: lintResults,
|
|
694
|
+
baselineNodeId: bundle.lintBaselineNodeId,
|
|
695
|
+
writerNodeIds: bundle.writerNodeIds,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
const lintBlocked = lintAssessment?.status === "failed" ||
|
|
699
|
+
lintAssessment?.status === "unavailable";
|
|
664
700
|
const groups = [
|
|
665
|
-
{ name: "mock", commands: bundle.mockCommands },
|
|
666
|
-
{ name: "static", commands: bundle.staticCommands },
|
|
667
|
-
{ name: "behavior", commands: bundle.behaviorCommands },
|
|
701
|
+
{ name: "mock", commands: bundle.mockCommands, labels: bundle.mockEvidence?.commandLabels ?? [] },
|
|
702
|
+
{ name: "static", commands: bundle.staticCommands, labels: bundle.staticEvidence.commandLabels },
|
|
703
|
+
{ name: "behavior", commands: bundle.behaviorCommands, labels: bundle.behaviorEvidence.commandLabels },
|
|
668
704
|
];
|
|
669
|
-
|
|
670
|
-
|
|
705
|
+
const successfulLabels = new Map();
|
|
706
|
+
for (const group of lintBlocked ? [] : groups) {
|
|
707
|
+
for (const [index, command] of group.commands.entries()) {
|
|
671
708
|
const commandNumber = results.length + 1;
|
|
672
709
|
const result = await executeShellCommand({
|
|
673
710
|
command,
|
|
@@ -681,6 +718,11 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
681
718
|
},
|
|
682
719
|
});
|
|
683
720
|
results.push(result);
|
|
721
|
+
if (result.ok && group.labels[index]) {
|
|
722
|
+
const labels = successfulLabels.get(group.name) ?? [];
|
|
723
|
+
labels.push(group.labels[index]);
|
|
724
|
+
successfulLabels.set(group.name, labels);
|
|
725
|
+
}
|
|
684
726
|
if (!result.ok)
|
|
685
727
|
break;
|
|
686
728
|
}
|
|
@@ -713,20 +755,36 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
713
755
|
failureCategory: result.failureCategory,
|
|
714
756
|
command: result.command,
|
|
715
757
|
}));
|
|
716
|
-
const firstFailure =
|
|
758
|
+
const firstFailure = lintBlocked
|
|
759
|
+
? lintResults.find((result) => !result.ok)
|
|
760
|
+
: results
|
|
761
|
+
.filter((result) => !lintResults.includes(result))
|
|
762
|
+
.find((result) => !result.ok);
|
|
763
|
+
const lintSyntheticFailure = lintBlocked && !firstFailure
|
|
764
|
+
? {
|
|
765
|
+
failureCategory: "invalid-output",
|
|
766
|
+
stderr: lintAssessment?.blockingReasons.join("; ") ??
|
|
767
|
+
"frontend lint assessment failed",
|
|
768
|
+
}
|
|
769
|
+
: undefined;
|
|
717
770
|
let traceError;
|
|
718
771
|
try {
|
|
719
772
|
await runFrontendVerificationTraceGate({
|
|
720
773
|
runDir: meta.runDir,
|
|
721
774
|
workspaceRoot: input.cwd,
|
|
722
775
|
evidence: {
|
|
776
|
+
mock: {
|
|
777
|
+
nodeId: input.task.id,
|
|
778
|
+
commandLabels: successfulLabels.get("mock") ?? [],
|
|
779
|
+
commandTexts: bundle.mockCommands.slice(0, successfulLabels.get("mock")?.length ?? 0),
|
|
780
|
+
},
|
|
723
781
|
static: {
|
|
724
782
|
nodeId: input.task.id,
|
|
725
|
-
commandLabels:
|
|
783
|
+
commandLabels: successfulLabels.get("static") ?? [],
|
|
726
784
|
},
|
|
727
785
|
behavior: {
|
|
728
786
|
nodeId: input.task.id,
|
|
729
|
-
commandLabels:
|
|
787
|
+
commandLabels: successfulLabels.get("behavior") ?? [],
|
|
730
788
|
},
|
|
731
789
|
},
|
|
732
790
|
});
|
|
@@ -735,19 +793,26 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
735
793
|
traceError = error instanceof Error ? error : new Error(String(error));
|
|
736
794
|
}
|
|
737
795
|
if (bundle.mode === "repair") {
|
|
738
|
-
if (firstFailure || traceError) {
|
|
796
|
+
if (firstFailure || lintSyntheticFailure || traceError) {
|
|
739
797
|
return {
|
|
740
798
|
ok: false,
|
|
741
799
|
stdout: summarizeCommandResults(results).stdout,
|
|
742
|
-
stderr: firstFailure?.stderr ||
|
|
743
|
-
|
|
800
|
+
stderr: firstFailure?.stderr ||
|
|
801
|
+
lintSyntheticFailure?.stderr ||
|
|
802
|
+
traceError?.message ||
|
|
803
|
+
"frontend reverify failed",
|
|
804
|
+
failureCategory: firstFailure?.failureCategory ??
|
|
805
|
+
lintSyntheticFailure?.failureCategory ??
|
|
806
|
+
"invalid-output",
|
|
744
807
|
durationMs: Date.now() - started,
|
|
745
808
|
...{ commandResults },
|
|
746
809
|
};
|
|
747
810
|
}
|
|
748
811
|
return {
|
|
749
812
|
ok: true,
|
|
750
|
-
stdout:
|
|
813
|
+
stdout: lintAssessment?.status === "baseline-debt"
|
|
814
|
+
? "Frontend reverify bundle: pass with lint baseline-debt"
|
|
815
|
+
: "Frontend reverify bundle: pass",
|
|
751
816
|
stderr: "",
|
|
752
817
|
failureCategory: "success",
|
|
753
818
|
durationMs: Date.now() - started,
|
|
@@ -755,14 +820,22 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
755
820
|
};
|
|
756
821
|
}
|
|
757
822
|
const failureFacts = [];
|
|
758
|
-
if (firstFailure) {
|
|
823
|
+
if (firstFailure || lintSyntheticFailure) {
|
|
824
|
+
const failureStdout = firstFailure
|
|
825
|
+
? [firstFailure.command, firstFailure.stdout]
|
|
826
|
+
.filter(Boolean)
|
|
827
|
+
.join("\n")
|
|
828
|
+
: "";
|
|
759
829
|
failureFacts.push({
|
|
760
830
|
nodeId: input.task.id,
|
|
761
831
|
record: {
|
|
762
832
|
status: "FINISHED",
|
|
763
|
-
failureCategory: firstFailure
|
|
764
|
-
|
|
765
|
-
|
|
833
|
+
failureCategory: firstFailure?.failureCategory ??
|
|
834
|
+
lintSyntheticFailure?.failureCategory,
|
|
835
|
+
// Keep classification scoped to the failed command. Aggregate
|
|
836
|
+
// successful output may contain unrelated writeSet-like JSON.
|
|
837
|
+
stdout: failureStdout,
|
|
838
|
+
stderr: firstFailure?.stderr ?? lintSyntheticFailure?.stderr,
|
|
766
839
|
commandResults,
|
|
767
840
|
},
|
|
768
841
|
});
|
|
@@ -803,6 +876,68 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
803
876
|
};
|
|
804
877
|
}
|
|
805
878
|
}
|
|
879
|
+
async function executeFrontendLintBaseline(input, meta) {
|
|
880
|
+
const started = Date.now();
|
|
881
|
+
const shell = input.task.shell;
|
|
882
|
+
const baseline = shell.frontendLintBaseline;
|
|
883
|
+
const cwd = resolveShellCwd(input.cwd, shell.cwd);
|
|
884
|
+
let beforeStatus;
|
|
885
|
+
try {
|
|
886
|
+
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
beforeStatus = undefined;
|
|
890
|
+
}
|
|
891
|
+
const results = [];
|
|
892
|
+
for (const command of baseline.lintCommands) {
|
|
893
|
+
const commandNumber = results.length + 1;
|
|
894
|
+
results.push(await executeShellCommand({
|
|
895
|
+
command,
|
|
896
|
+
cwd,
|
|
897
|
+
timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
|
|
898
|
+
envAllowlist: shell.envAllowlist,
|
|
899
|
+
dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
|
|
900
|
+
outputArtifacts: {
|
|
901
|
+
stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
|
|
902
|
+
stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
|
|
903
|
+
},
|
|
904
|
+
}));
|
|
905
|
+
}
|
|
906
|
+
let afterStatus;
|
|
907
|
+
try {
|
|
908
|
+
afterStatus = await readGitStatusPorcelain(input.cwd);
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
afterStatus = undefined;
|
|
912
|
+
}
|
|
913
|
+
try {
|
|
914
|
+
const materialized = await materializeFrontendLintBaseline({
|
|
915
|
+
runDir: meta.runDir,
|
|
916
|
+
workspaceRoot: input.cwd,
|
|
917
|
+
commands: baseline.lintCommands,
|
|
918
|
+
results,
|
|
919
|
+
worktreeChanged: beforeStatus === undefined ||
|
|
920
|
+
afterStatus === undefined ||
|
|
921
|
+
beforeStatus !== afterStatus,
|
|
922
|
+
});
|
|
923
|
+
return {
|
|
924
|
+
ok: true,
|
|
925
|
+
stdout: `Frontend lint baseline: ${materialized.artifact.status}\nArtifact: ${materialized.ref.path}\nDiagnostics: ${materialized.artifact.diagnostics.length}`,
|
|
926
|
+
stderr: materialized.artifact.reason ?? "",
|
|
927
|
+
failureCategory: "success",
|
|
928
|
+
durationMs: Date.now() - started,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
catch (error) {
|
|
932
|
+
return {
|
|
933
|
+
ok: false,
|
|
934
|
+
stdout: "",
|
|
935
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
936
|
+
failureCategory: "invalid-output",
|
|
937
|
+
durationMs: Date.now() - started,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
}
|
|
806
941
|
export async function executeDagShellNode(input, meta) {
|
|
807
942
|
const shell = input.task.shell;
|
|
808
943
|
if (shell?.frontendPrewriteGate) {
|
|
@@ -812,6 +947,7 @@ export async function executeDagShellNode(input, meta) {
|
|
|
812
947
|
runDir: meta.runDir,
|
|
813
948
|
config: shell.frontendPrewriteGate,
|
|
814
949
|
sourceBinding: meta.spec.sourceBinding,
|
|
950
|
+
workspaceRoot: input.cwd,
|
|
815
951
|
repoRoot: input.cwd,
|
|
816
952
|
});
|
|
817
953
|
return { ok: true, stdout: formatFrontendPrewriteGateStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
@@ -820,13 +956,20 @@ export async function executeDagShellNode(input, meta) {
|
|
|
820
956
|
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
821
957
|
}
|
|
822
958
|
}
|
|
959
|
+
if (shell?.frontendLintBaseline) {
|
|
960
|
+
return executeFrontendLintBaseline(input, meta);
|
|
961
|
+
}
|
|
823
962
|
if (shell?.frontendVerificationBundle) {
|
|
824
963
|
return executeFrontendVerificationBundle(input, meta);
|
|
825
964
|
}
|
|
826
965
|
if (shell?.frontendReviewContext) {
|
|
827
966
|
const started = Date.now();
|
|
828
967
|
try {
|
|
829
|
-
const result = await runFrontendReviewContextGate({
|
|
968
|
+
const result = await runFrontendReviewContextGate({
|
|
969
|
+
runDir: meta.runDir,
|
|
970
|
+
workspaceRoot: input.cwd,
|
|
971
|
+
requireBaseline: shell.frontendReviewContext.requireBaseline,
|
|
972
|
+
});
|
|
830
973
|
return { ok: true, stdout: formatFrontendReviewContextStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
831
974
|
}
|
|
832
975
|
catch (error) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared openspec specification path predicates.
|
|
3
|
+
*
|
|
4
|
+
* Canonical frontend specification directories (case-sensitive) and extension
|
|
5
|
+
* allowlist.
|
|
6
|
+
* All consumers in candidate discovery, DAG schema, prewrite gate, and observe
|
|
7
|
+
* must use these predicates to ensure a single consistent scope definition.
|
|
8
|
+
*/
|
|
9
|
+
/** Canonical spec directories (case-sensitive, POSIX form). */
|
|
10
|
+
export const OPENSPEC_SPEC_DIRS = [
|
|
11
|
+
"openspec/schemas",
|
|
12
|
+
"openspec/project-specs",
|
|
13
|
+
"ai_workspace",
|
|
14
|
+
];
|
|
15
|
+
/** Allowed spec file extensions (case-insensitive). */
|
|
16
|
+
export const OPENSPEC_SPEC_EXT_RE = /\.(md|mdx|json|yaml|yml)$/i;
|
|
17
|
+
/**
|
|
18
|
+
* Returns `true` when `filePath` is inside one of the canonical frontend spec
|
|
19
|
+
* directories. `filePath` must be a repo-relative POSIX path (backslashes are
|
|
20
|
+
* normalised internally). Directory names are case-sensitive.
|
|
21
|
+
*/
|
|
22
|
+
export function isOpenspecSpecPath(filePath) {
|
|
23
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
24
|
+
for (const dir of OPENSPEC_SPEC_DIRS) {
|
|
25
|
+
if (normalized === dir || normalized.startsWith(dir + "/"))
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns `true` when `filename` (the basename only, not a full path) has an
|
|
32
|
+
* allowed frontend specification extension.
|
|
33
|
+
*/
|
|
34
|
+
export function isValidOpenspecExtension(filename) {
|
|
35
|
+
return OPENSPEC_SPEC_EXT_RE.test(filename);
|
|
36
|
+
}
|
|
37
|
+
/** Returns `true` for a supported file inside a canonical openspec spec dir. */
|
|
38
|
+
export function isOpenspecSpecFilePath(filePath) {
|
|
39
|
+
return (isOpenspecSpecPath(filePath) &&
|
|
40
|
+
isValidOpenspecExtension(filePath.replaceAll("\\", "/")));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Returns `true` when free-form search input references a canonical frontend
|
|
44
|
+
* specification directory with exact lowercase directory names.
|
|
45
|
+
*/
|
|
46
|
+
export function isOpenspecSpecSearchTarget(value) {
|
|
47
|
+
const normalized = value.replaceAll("\\", "/");
|
|
48
|
+
return /(?:^|[^A-Za-z0-9_.-])(?:openspec\/(?:schemas|project-specs)|ai_workspace)(?:\/|$)/.test(normalized);
|
|
49
|
+
}
|
|
@@ -8,6 +8,7 @@ import { getTaskPoolRoot, listLegacyStateFiles, listTaskPoolStates, } from "../p
|
|
|
8
8
|
import { assessDagRunLiveness, assessDagRunRecoveryEligibility, deriveDagRunEffectiveStatus, } from "../../workflows/dag/lifecycle.js";
|
|
9
9
|
import { parseDagSpec, resolveModelForTask, } from "../../workflows/dag/types.js";
|
|
10
10
|
import { loadFeatureDecisionModels } from "../feature/decision-loader.js";
|
|
11
|
+
import { frontendLintAssessmentArtifactSchema } from "../../workflows/dag/frontend-lint-baseline.js";
|
|
11
12
|
/** Internal map key: featureId + NUL + taskId (stable, non-printable delimiter). */
|
|
12
13
|
export function taskIdentityKey(featureId, taskId) {
|
|
13
14
|
return `${featureId}\0${taskId}`;
|
|
@@ -1525,6 +1526,21 @@ async function loadBackendTestProjection(runDir, nodes) {
|
|
|
1525
1526
|
...(manifest && manifest.coverageSummary ? { coverage: manifest.coverageSummary } : {}),
|
|
1526
1527
|
};
|
|
1527
1528
|
}
|
|
1529
|
+
async function loadFrontendLintProjection(runDir) {
|
|
1530
|
+
const decoded = await safeReadJson(path.join(runDir, "contracts", "frontend-lint-assessment.json"));
|
|
1531
|
+
if (!decoded)
|
|
1532
|
+
return undefined;
|
|
1533
|
+
const parsed = frontendLintAssessmentArtifactSchema.safeParse(decoded);
|
|
1534
|
+
if (!parsed.success)
|
|
1535
|
+
return undefined;
|
|
1536
|
+
return {
|
|
1537
|
+
status: parsed.data.status,
|
|
1538
|
+
writerChangedFiles: parsed.data.writerChangedFiles,
|
|
1539
|
+
toleratedDiagnosticCount: parsed.data.toleratedDiagnosticCount,
|
|
1540
|
+
blockingDiagnosticCount: parsed.data.blockingDiagnostics.length,
|
|
1541
|
+
blockingReasons: parsed.data.blockingReasons,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1528
1544
|
async function parseDagStateFile(statePath, now) {
|
|
1529
1545
|
try {
|
|
1530
1546
|
if (!existsSync(statePath))
|
|
@@ -1579,7 +1595,10 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1579
1595
|
liveness: liveness.status,
|
|
1580
1596
|
})
|
|
1581
1597
|
: undefined;
|
|
1582
|
-
const backendTest = await
|
|
1598
|
+
const [backendTest, frontendLint] = await Promise.all([
|
|
1599
|
+
loadBackendTestProjection(runDir, nodes),
|
|
1600
|
+
loadFrontendLintProjection(runDir),
|
|
1601
|
+
]);
|
|
1583
1602
|
const continuationRecord = state.continuation &&
|
|
1584
1603
|
typeof state.continuation.parentRunId === "string" &&
|
|
1585
1604
|
typeof state.continuation.effectiveFromNodeId === "string" &&
|
|
@@ -1631,6 +1650,7 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1631
1650
|
dagPath: runDir,
|
|
1632
1651
|
...(continuation ? { continuation } : {}),
|
|
1633
1652
|
...(backendTest ? { backendTest } : {}),
|
|
1653
|
+
...(frontendLint ? { frontendLint } : {}),
|
|
1634
1654
|
};
|
|
1635
1655
|
}
|
|
1636
1656
|
catch {
|