@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agent-worker.js +0 -0
  3. package/dist/adapters/loop-agent.js +52 -0
  4. package/dist/commands/init.js +97 -0
  5. package/dist/executors/dag-pi-executor.js +2 -0
  6. package/dist/executors/shell-executor.js +162 -19
  7. package/dist/shared/openspec-spec.js +49 -0
  8. package/dist/worker/observability/read-model.js +21 -1
  9. package/dist/worker/observe/spec-evidence.js +12 -15
  10. package/dist/worker/observe/static/dag-helpers.js +22 -0
  11. package/dist/worker/observe/static/views/dag.js +5 -0
  12. package/dist/workflows/dag/backend-test-markdown-workflow.js +37 -0
  13. package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
  14. package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
  15. package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
  16. package/dist/workflows/dag/frontend-project-capability.js +11 -8
  17. package/dist/workflows/dag/frontend-repair.js +6 -4
  18. package/dist/workflows/dag/frontend-review-context.js +67 -0
  19. package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
  20. package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
  21. package/dist/workflows/dag/frontend-verification-trace.js +31 -1
  22. package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
  23. package/dist/workflows/dag/init-hybrid.js +344 -64
  24. package/dist/workflows/dag/types.js +62 -1
  25. package/docs/templates/agent-dag.schema.json +15 -5
  26. package/docs/templates/backend-test-dag.json +1 -1
  27. package/docs/templates/frontend-implementation-contract.schema.json +4 -3
  28. package/docs/templates/frontend-test-case-checklist.md +6 -2
  29. package/docs/templates/frontend-test-dag.json +2 -2
  30. package/package.json +1 -1
  31. package/skills/frontend-design-review/SKILL.md +12 -10
  32. package/skills/frontend-design-review/references/review-checklist.md +4 -4
  33. package/skills/frontend-implementation/SKILL.md +2 -2
  34. package/skills/frontend-implementation/references/code-standards.md +4 -3
  35. package/skills/frontend-implementation/references/design-spec.md +19 -14
  36. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  37. package/skills/frontend-review/SKILL.md +15 -28
  38. package/skills/frontend-review/references/review-findings.md +16 -18
  39. package/skills/frontend-verification/SKILL.md +16 -13
  40. 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
  ### 修复
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;
@@ -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
- `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -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
+ '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
- for (const group of groups) {
670
- for (const command of group.commands) {
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 = results.find((result) => !result.ok);
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: bundle.staticEvidence.commandLabels,
783
+ commandLabels: successfulLabels.get("static") ?? [],
726
784
  },
727
785
  behavior: {
728
786
  nodeId: input.task.id,
729
- commandLabels: bundle.behaviorEvidence.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 || traceError?.message || "frontend reverify failed",
743
- failureCategory: firstFailure?.failureCategory ?? "invalid-output",
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: "Frontend reverify bundle: pass",
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.failureCategory,
764
- stdout: summarizeCommandResults(results).stdout,
765
- stderr: firstFailure.stderr,
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({ runDir: meta.runDir, workspaceRoot: input.cwd });
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 loadBackendTestProjection(runDir, nodes);
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 {