@tea-agent/loop-agent 0.21.0 → 0.23.1

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 (108) hide show
  1. package/AGENTS.md +42 -108
  2. package/CHANGELOG.md +114 -0
  3. package/README.md +7 -4
  4. package/bin/agent-worker.js +0 -0
  5. package/dist/adapters/loop-agent.js +52 -0
  6. package/dist/application/context-usage/skill-resolution-stats.js +263 -0
  7. package/dist/application/dag/generate-task-dag.js +17 -3
  8. package/dist/cli/command-definitions.js +8 -7
  9. package/dist/cli/program.js +17 -15
  10. package/dist/commands/doctor.js +269 -18
  11. package/dist/commands/init.js +198 -86
  12. package/dist/commands/stats.js +40 -11
  13. package/dist/executors/dag-pi-executor.js +2 -0
  14. package/dist/executors/shell-executor.js +162 -19
  15. package/dist/shared/openspec-spec.js +49 -0
  16. package/dist/shared/operator/capabilities.js +11 -1
  17. package/dist/worker/console/app-data.js +4 -0
  18. package/dist/worker/console/chat/instruction-skills.js +217 -0
  19. package/dist/worker/console/chat/model-resolver.js +106 -0
  20. package/dist/worker/console/chat/pi-runtime.js +605 -0
  21. package/dist/worker/console/chat/resource-loader.js +66 -0
  22. package/dist/worker/console/chat/routes.js +357 -0
  23. package/dist/worker/console/chat/session-store.js +238 -0
  24. package/dist/worker/console/chat/tool-adapter.js +238 -0
  25. package/dist/worker/console/chat/tools.js +171 -0
  26. package/dist/worker/console/server.js +55 -0
  27. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +25 -0
  28. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +1 -0
  29. package/dist/worker/console/static/index.html +2 -2
  30. package/dist/worker/feature/profile-schema.js +1 -1
  31. package/dist/worker/observability/read-model.js +21 -1
  32. package/dist/worker/observe/spec-evidence.js +12 -15
  33. package/dist/worker/observe/static/dag-helpers.js +22 -0
  34. package/dist/worker/observe/static/views/dag.js +5 -0
  35. package/dist/workflows/dag/backend-test-markdown-workflow.js +37 -0
  36. package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
  37. package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
  38. package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
  39. package/dist/workflows/dag/frontend-project-capability.js +11 -8
  40. package/dist/workflows/dag/frontend-repair.js +6 -4
  41. package/dist/workflows/dag/frontend-review-context.js +67 -0
  42. package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
  43. package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
  44. package/dist/workflows/dag/frontend-verification-trace.js +31 -1
  45. package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
  46. package/dist/workflows/dag/init-hybrid.js +412 -84
  47. package/dist/workflows/dag/node-execution.js +38 -1
  48. package/dist/workflows/dag/output-protocol.js +89 -0
  49. package/dist/workflows/dag/prompt.js +35 -1
  50. package/dist/workflows/dag/recovery-recommendation.js +45 -0
  51. package/dist/workflows/dag/report.js +28 -1
  52. package/dist/workflows/dag/rerun-task.js +1 -1
  53. package/dist/workflows/dag/scheduler.js +9 -0
  54. package/dist/workflows/dag/types.js +74 -1
  55. package/dist/workflows/dag/validate.js +55 -0
  56. package/docs/README.md +73 -156
  57. package/docs/architecture/README.md +3 -2
  58. package/docs/architecture/dag-execution.md +2 -2
  59. package/docs/architecture/evolution.md +14 -12
  60. package/docs/architecture/system-overview.md +1 -1
  61. package/docs/architecture/worker-and-feature.md +3 -3
  62. package/docs/governance/README.md +15 -0
  63. package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
  64. package/docs/init-surface.manifest.json +22 -4
  65. package/docs/operations/README.md +12 -0
  66. package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
  67. package/docs/skills/vetted-skill-registry.md +23 -3
  68. package/docs/templates/README.md +55 -0
  69. package/docs/templates/agent-dag.schema.json +15 -5
  70. package/docs/templates/backend-test-dag.json +1 -1
  71. package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
  72. package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
  73. package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
  74. package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
  75. package/docs/templates/frontend-implementation-contract.schema.json +4 -3
  76. package/docs/templates/frontend-test-case-checklist.md +6 -2
  77. package/docs/templates/frontend-test-dag.json +2 -2
  78. package/docs/templates/hybrid-dag.json +1 -1
  79. package/docs/templates/progress-log.md +9 -2
  80. package/harness.json +5 -5
  81. package/package.json +5 -5
  82. package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
  83. package/skills/agent-worker/SKILL.md +1 -1
  84. package/skills/frontend-design-review/SKILL.md +12 -10
  85. package/skills/frontend-design-review/references/review-checklist.md +4 -4
  86. package/skills/frontend-implementation/SKILL.md +2 -2
  87. package/skills/frontend-implementation/references/code-standards.md +4 -3
  88. package/skills/frontend-implementation/references/design-spec.md +19 -14
  89. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  90. package/skills/frontend-review/SKILL.md +15 -28
  91. package/skills/frontend-review/references/review-findings.md +16 -18
  92. package/skills/frontend-verification/SKILL.md +16 -13
  93. package/skills/frontend-verification/references/verification-checklist.md +18 -30
  94. package/skills/grill-with-docs/SKILL.md +44 -52
  95. package/skills/grill-with-docs/adr-format.md +37 -26
  96. package/skills/grill-with-docs/context-format.md +18 -26
  97. package/skills/loop-agent/SKILL.md +28 -112
  98. package/skills/loop-agent/references/command-reference.md +9 -3
  99. package/skills/loop-agent/references/harness-policy.md +3 -3
  100. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  101. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  102. package/skills/loop-agent/references/task-workflow.md +2 -0
  103. package/skills/systematic-debugging/SKILL.md +20 -4
  104. package/skills/test-driven-development/SKILL.md +10 -3
  105. package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
  106. package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
  107. /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
  108. /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
@@ -0,0 +1 @@
1
+ :root{--canvas: #f7f7f4;--canvas-soft: #fafaf7;--surface: #ffffff;--surface-muted: #f0efe9;--ink: #26251e;--body: #5a5852;--muted: #807d72;--muted-soft: #a09c92;--hairline: #e6e5e0;--hairline-strong: #cfcdc4;--orange: #f54e00;--orange-active: #d04200;--orange-soft: color-mix(in srgb, var(--orange) 12%, white);--blue: #3f6ea4;--green: #1f8a65;--amber: #a66a00;--red: #c72d53;--peach: #f1d8cb;--mint: #d8ead5;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-8: 32px;--radius-sm: 6px;--radius-md: 8px;--radius-lg: 12px;--radius-pill: 999px;--transition-fast: .16s ease;--transition-base: .22s ease;--shadow-panel: 0 1px 2px rgb(38 37 30 / 4%), 0 8px 24px rgb(38 37 30 / 3%);--shadow-soft: 0 10px 30px rgb(38 37 30 / 6%);color-scheme:light;font-family:Segoe UI,PingFang SC,Hiragino Sans GB,Noto Sans SC,system-ui,-apple-system,sans-serif;line-height:1.5}*{box-sizing:border-box}html{background:radial-gradient(1200px 480px at 8% -10%,rgb(241 216 203 / 45%),transparent 55%),radial-gradient(900px 420px at 100% 0%,rgb(216 234 213 / 35%),transparent 50%),var(--canvas);min-height:100%}body{margin:0;min-height:100vh;min-width:320px;background:transparent;color:var(--ink);font-size:14px}button,input,select,textarea{font:inherit}a{color:inherit}code,.cmd,.kv dd code{font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace}.embedded-operator-nav{position:sticky;top:0;z-index:40;display:flex;gap:6px;align-items:center;margin:0;padding:8px max(24px,calc((100vw - 1120px)/2));border-bottom:1px solid var(--hairline);background:color-mix(in srgb,var(--canvas) 92%,transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);font-size:13px;font-weight:700}.embedded-operator-nav a,.embedded-operator-nav span{padding:6px 12px;border-radius:var(--radius-pill);text-decoration:none;color:var(--ink)}.embedded-operator-nav a:hover,.embedded-operator-nav a:focus-visible{background:var(--surface);color:var(--orange-active);outline:none}.embedded-operator-nav span[aria-current=page]{background:var(--ink);color:#fff}.console-shell{width:min(100% - 48px,1120px);margin:0 auto;padding:var(--space-5) 0 var(--space-12, 48px)}.console-header{display:grid;gap:var(--space-4);grid-template-columns:1fr auto;align-items:center;margin-bottom:var(--space-4);padding-bottom:var(--space-4);border-bottom:1px solid var(--hairline)}.brand-block{display:grid;gap:2px;align-content:start;min-width:0}h1{margin:0;color:var(--ink);font-size:clamp(20px,2.2vw,24px);font-weight:650;line-height:1.2;letter-spacing:-.02em}.subtitle{max-width:58ch;margin:0;color:var(--body);font-size:13px;line-height:1.4}.top-nav{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);margin:0 0 var(--space-5);padding:6px;border:1px solid var(--hairline);border-radius:var(--radius-pill);background:color-mix(in srgb,var(--surface) 88%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);box-shadow:var(--shadow-panel)}.nav-link{appearance:none;display:inline-flex;align-items:center;min-height:34px;padding:0 14px;border:1px solid transparent;border-radius:var(--radius-pill);background:transparent;color:var(--body);font-size:13px;font-weight:600;cursor:pointer;transition:background-color var(--transition-fast),color var(--transition-fast),border-color var(--transition-fast),transform var(--transition-fast)}.nav-link:hover:not(:disabled),.nav-link:focus-visible:not(:disabled){background:var(--surface);color:var(--ink);outline:none}.nav-link-active{background:var(--ink);color:#fff}.nav-link-active:hover:not(:disabled),.nav-link-active:focus-visible:not(:disabled){background:var(--ink);color:#fff}.nav-link-external{margin-left:auto;border-color:var(--hairline);background:var(--surface);color:var(--orange-active)}.nav-link-external:hover:not(:disabled){border-color:var(--orange);background:var(--orange-soft);color:var(--orange-active)}.readiness{position:relative;display:grid;gap:2px;min-width:140px;max-width:280px;border:1px solid var(--hairline);background:var(--surface);border-radius:var(--radius-md);padding:8px 32px 8px 12px;box-shadow:var(--shadow-panel)}.readiness:before{content:"";position:absolute;top:50%;right:12px;width:8px;height:8px;border-radius:50%;transform:translateY(-50%);background:var(--muted-soft)}.readiness[data-state=setup-required]{border-color:var(--hairline);background:var(--surface)}.readiness[data-state=setup-required]:before{background:var(--amber)}.readiness[data-state=ready]{border-color:var(--hairline);background:var(--surface)}.readiness[data-state=ready]:before{background:color-mix(in srgb,var(--green) 72%,white);box-shadow:0 0 0 3px color-mix(in srgb,var(--green) 12%,transparent)}.readiness[data-state=degraded]{border-color:var(--hairline);background:var(--surface)}.readiness[data-state=degraded]:before{background:var(--amber);box-shadow:0 0 0 3px color-mix(in srgb,var(--amber) 12%,transparent)}.readiness-label{display:block;margin:0;color:var(--muted);font-size:11px;font-weight:600;line-height:1.2}.readiness strong{color:var(--ink);font-size:14px;font-weight:700;line-height:1.2}.readiness[data-state=ready] strong,.readiness[data-state=setup-required] strong,.readiness[data-state=degraded] strong{color:var(--ink)}.readiness .muted{margin:2px 0 0;font-size:12px;line-height:1.35}.flash{display:flex;gap:var(--space-3);align-items:flex-start;margin:0 0 var(--space-4);padding:12px 14px;border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface);box-shadow:var(--shadow-panel);animation:flash-in .22s ease}.flash-error{border-color:color-mix(in srgb,var(--red) 35%,var(--hairline));background:color-mix(in srgb,var(--red) 6%,var(--surface));color:var(--red)}.flash-info{border-color:color-mix(in srgb,var(--blue) 30%,var(--hairline));background:color-mix(in srgb,var(--blue) 6%,var(--surface));color:var(--body)}.flash-label{flex:0 0 auto;min-width:3.5rem;font-size:12px;font-weight:700;letter-spacing:.02em}.flash-body{margin:0;overflow-wrap:anywhere}.muted{color:var(--muted)}.console-main{display:grid;gap:var(--space-4)}.inspect-split-panel{position:fixed;z-index:50;top:16px;right:16px;bottom:16px;width:min(420px,calc(100vw - 32px));display:grid;align-content:start;gap:var(--space-4);padding:var(--space-5);overflow:auto;border:1px solid var(--hairline);border-radius:var(--radius-lg);background:var(--surface);box-shadow:var(--shadow-soft)}.inspect-split-nodes{display:grid;gap:6px;margin:0;padding:0;list-style:none;max-height:220px;overflow:auto}.inspect-split-nodes li{display:grid;grid-template-columns:1fr auto;gap:var(--space-2);align-items:center;padding:6px 8px;border:1px solid var(--hairline);border-radius:var(--radius-sm);background:var(--canvas-soft);font-size:12px}.inspect-split-nodes li.is-active{border-color:var(--orange);background:var(--orange-soft)}.inspect-split-nodes code{color:var(--muted);font-size:11px}.inspect-split-header{display:flex;align-items:start;justify-content:space-between;gap:var(--space-3)}.inspect-split-header h2,.inspect-split-panel h3{margin:0}.inspect-split-kicker,.inspect-split-id{margin:0;color:var(--muted);font-size:12px}.inspect-split-facts{display:grid;gap:var(--space-2);margin:0}.inspect-split-facts div{display:grid;grid-template-columns:7rem 1fr;gap:var(--space-2);padding-bottom:var(--space-2);border-bottom:1px solid var(--hairline)}.inspect-split-facts dt{color:var(--muted)}.inspect-split-facts dd{margin:0;overflow-wrap:anywhere}.inspect-split-events{display:grid;gap:8px;margin:0;padding-left:18px;color:var(--body);font-size:12px}.panel{min-width:0;border:1px solid var(--hairline);background:color-mix(in srgb,var(--surface) 96%,transparent);border-radius:var(--radius-lg);padding:var(--space-6);box-shadow:var(--shadow-panel)}.panel h2{margin:0 0 var(--space-2);color:var(--ink);font-size:18px;font-weight:700;letter-spacing:-.01em}.panel-lead{margin:0 0 var(--space-5);color:var(--muted);font-size:13px}.panel h3{margin:0 0 var(--space-3);color:var(--ink);font-size:15px;font-weight:700}.empty-state{border:1px dashed var(--hairline-strong);border-radius:var(--radius-md);padding:var(--space-6);background:var(--canvas-soft);color:var(--body)}.empty-title{margin:0 0 var(--space-2);color:var(--ink);font-size:16px;font-weight:700}.checklist{margin:0 0 var(--space-4);padding:var(--space-4);border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--canvas-soft);list-style:none;color:var(--body)}.checklist li{position:relative;padding:6px 0 6px 22px}.checklist li:before{content:"";position:absolute;left:0;top:12px;width:8px;height:8px;border-radius:50%;background:var(--orange);opacity:.75}.kv{display:grid;gap:var(--space-1);margin:0}.kv div{display:grid;grid-template-columns:9rem 1fr;gap:var(--space-3);padding:10px 0;border-bottom:1px solid var(--hairline)}.kv div:last-child{border-bottom:none}.kv dt{color:var(--muted);font-size:12px;font-weight:600}.kv dd{margin:0;color:var(--ink);overflow-wrap:anywhere}code{font-size:.85rem;color:var(--ink)}.cmd{display:block;width:100%;margin:0;padding:var(--space-4);border:1px solid var(--hairline);border-radius:var(--radius-md);background:#f3efe8;color:var(--ink);font-size:12px;line-height:1.55;overflow-x:auto;white-space:pre-wrap;word-break:break-word;max-height:280px}.external-link{color:var(--orange);text-decoration:none;font-weight:600}.external-link:hover{text-decoration:underline}.error{color:var(--red)}.wizard-steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(108px,1fr));gap:var(--space-2);list-style:none;padding:0;margin:0 0 var(--space-5)}.wizard-steps li{position:relative;min-height:52px;display:grid;align-content:center;gap:2px;border:1px solid var(--hairline);border-radius:var(--radius-md);padding:8px 10px;background:var(--canvas-soft);color:var(--muted);font-size:12px;font-weight:650;transition:border-color var(--transition-fast),background-color var(--transition-fast),color var(--transition-fast),box-shadow var(--transition-fast)}.wizard-steps li[data-done=true]{border-color:color-mix(in srgb,var(--green) 35%,var(--hairline));background:color-mix(in srgb,var(--mint) 45%,var(--surface));color:var(--green)}.wizard-steps li[data-active=true]{border-color:var(--orange);background:var(--surface);color:var(--ink);box-shadow:0 0 0 3px var(--orange-soft)}.wizard-steps .step-index{font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted-soft)}.wizard-steps li[data-active=true] .step-index,.wizard-steps li[data-done=true] .step-index{color:inherit;opacity:.8}.wizard-steps .step-label{line-height:1.25}.field{display:grid;gap:var(--space-1);margin:0 0 var(--space-4);width:min(100%,520px)}.field span{font-size:12px;font-weight:600;color:var(--muted)}.field input,.field textarea,.field select{width:100%;border:1px solid var(--hairline-strong);border-radius:var(--radius-md);background:var(--surface);color:var(--ink);transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.field input{min-height:40px;padding:0 var(--space-3)}.field select{min-height:40px;padding:0 var(--space-3);cursor:pointer}.field textarea{min-height:120px;padding:var(--space-3);resize:vertical}.field input:focus-visible,.field textarea:focus-visible,.field select:focus-visible{outline:none;border-color:var(--orange);box-shadow:0 0 0 3px var(--orange-soft)}.field input:disabled,.field textarea:disabled,.field select:disabled{background:var(--surface-muted);color:var(--muted);cursor:not-allowed}.workflow-hint{margin:calc(var(--space-1) * -1) 0 var(--space-3);color:var(--muted);font-size:12px}.meta-row{display:grid;gap:6px;margin:0 0 var(--space-4);padding:var(--space-3) var(--space-4);border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--canvas-soft);font-size:13px}.meta-row code{font-size:12px}.cta-row,.recovery-cta-row{display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center}.cta-row{margin-top:var(--space-3)}.cta-row .cmd{flex:1 1 100%}.wizard-body{display:grid;gap:var(--space-3);justify-items:stretch;animation:panel-in .24s ease}.wizard-body>div{display:grid;gap:var(--space-3);justify-items:stretch}.wizard-body .field{width:100%;max-width:640px}.wizard-body .cta-row{margin-top:var(--space-1)}.interview-card{width:100%;padding:var(--space-5);border:1px solid var(--hairline);border-radius:var(--radius-lg);background:linear-gradient(180deg,var(--surface),var(--canvas-soft));box-shadow:var(--shadow-panel)}.interview-q{margin:0 0 var(--space-2);color:var(--ink)}.btn,button{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:40px;padding:0 16px;border:1px solid var(--hairline-strong);border-radius:var(--radius-md);background:var(--surface);color:var(--ink);font-size:13px;font-weight:650;cursor:pointer;transition:border-color var(--transition-fast),background-color var(--transition-fast),color var(--transition-fast),box-shadow var(--transition-fast),transform var(--transition-fast)}.btn:hover:not(:disabled),button:hover:not(:disabled){border-color:color-mix(in srgb,var(--ink) 18%,var(--hairline-strong));background:var(--canvas-soft);outline:none}.btn:focus-visible:not(:disabled),button:focus-visible:not(:disabled){outline:none;box-shadow:0 0 0 3px var(--orange-soft)}.btn:active:not(:disabled),button:active:not(:disabled){transform:translateY(1px)}.btn:disabled,button:disabled{opacity:.48;cursor:not-allowed;transform:none}.btn-primary,button.btn-primary{border-color:var(--orange);background:var(--orange);color:#fff;box-shadow:0 6px 16px #f54e002e}.btn-primary:hover:not(:disabled),button.btn-primary:hover:not(:disabled){border-color:var(--orange-active);background:var(--orange-active);color:#fff}.btn-secondary,button.btn-secondary{border-color:var(--hairline-strong);background:var(--surface);color:var(--ink)}.btn-ghost,button.btn-ghost{border-color:transparent;background:transparent;color:var(--body)}.btn-ghost:hover:not(:disabled),button.btn-ghost:hover:not(:disabled){background:var(--surface-muted);color:var(--ink)}.recovery-cta-row{margin-top:var(--space-3)}.recovery-confirm{margin-top:var(--space-3);padding:var(--space-4);border:1px solid color-mix(in srgb,var(--orange) 40%,var(--hairline));border-radius:var(--radius-md);background:color-mix(in srgb,var(--orange-soft) 55%,var(--surface))}.recovery-confirm h3{margin:0 0 var(--space-2);font-size:15px}.recovery-confirm .cta-row{margin-top:var(--space-3)}.recovery-report{margin-top:var(--space-3);padding:var(--space-4);border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface)}.recovery-report-header{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);margin-bottom:var(--space-3)}.recovery-report-header h3{margin:0;font-size:15px}.recovery-report-facts{display:grid;gap:var(--space-2);margin:0 0 var(--space-3)}.recovery-report-facts div{display:grid;grid-template-columns:5.5rem 1fr;gap:var(--space-2)}.recovery-report-facts dt{color:var(--muted);font-size:12px}.recovery-report-facts dd{margin:0;overflow-wrap:anywhere}.recovery-steps{margin:.5rem 0 .75rem 1.25rem;padding:0;line-height:1.5}.recovery-lane{margin-top:var(--space-4);padding-top:var(--space-3);border-top:1px solid var(--border, #ddd)}.recovery-lane h3{margin:0 0 .35rem;font-size:1rem}.recovery-cta-row button[disabled]{opacity:.4}@keyframes flash-in{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}@keyframes panel-in{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}@media(max-width:800px){.console-shell{width:min(100% - 24px,1120px);padding-top:var(--space-5)}.console-header{grid-template-columns:1fr}.nav-link-external{margin-left:0}.inspect-split-panel{inset:auto 0 0;width:100%;max-height:min(76vh,680px);border-radius:var(--radius-lg) var(--radius-lg) 0 0}.kv div{grid-template-columns:1fr}.wizard-steps{grid-template-columns:repeat(2,minmax(0,1fr))}}.oc-shell{display:flex;flex-direction:column;height:100%;min-height:0;gap:var(--space-3);position:relative}.oc-workspace{display:flex;flex:1 1 auto;min-height:0;gap:0;position:relative}.oc-main{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0}.oc-process{display:flex;flex-direction:column;flex:0 0 min(360px,42%);width:min(360px,42%);min-width:280px;min-height:0;border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface);box-shadow:var(--shadow-panel);margin-left:var(--space-3);overflow:hidden;animation:panel-in .18s ease}.oc-process-head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:10px 12px;border-bottom:1px solid var(--hairline);flex-shrink:0}.oc-process-title{display:inline-flex;align-items:center;gap:8px;font-weight:700;font-size:13px;color:var(--ink)}.oc-process-title-mark{width:8px;height:8px;border-radius:50%;background:var(--orange)}.oc-process-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:18px;padding:0 6px;border-radius:var(--radius-pill);background:var(--surface-muted);color:var(--muted);font-size:11px;font-weight:650}.oc-process-close{font-size:12px}.oc-process-timeline{flex:1 1 auto;min-height:0;overflow-y:auto;padding:var(--space-3);display:flex;flex-direction:column;gap:0}.oc-process-empty{padding:var(--space-4);color:var(--muted);font-size:12.5px;line-height:1.5;text-align:center}.oc-process-item{display:grid;grid-template-columns:20px 52px minmax(0,1fr);gap:8px;align-items:start;padding:4px 0}.oc-process-rail{position:relative;display:grid;place-items:center;min-height:34px}.oc-process-rail:before{position:absolute;top:0;bottom:0;width:1px;background:var(--hairline);content:""}.oc-process-item.is-first .oc-process-rail:before{top:50%}.oc-process-item.is-last .oc-process-rail:before{bottom:50%}.oc-process-dot{position:relative;z-index:1;width:8px;height:8px;border-radius:50%;background:var(--surface);border:2px solid color-mix(in srgb,var(--orange) 55%,var(--hairline))}.oc-process-item.is-running .oc-process-dot{border-color:var(--orange-active);background:var(--orange-soft)}.oc-process-item.is-error .oc-process-dot{border-color:var(--red);background:color-mix(in srgb,var(--red) 18%,var(--surface))}.oc-process-time{padding-top:10px;font-size:11px;color:var(--muted-soft);font-variant-numeric:tabular-nums;white-space:nowrap}.oc-process-body{min-width:0}.oc-process-toggle{cursor:pointer;font:inherit}.oc-process-toggle.is-active{border-color:color-mix(in srgb,var(--orange) 40%,var(--hairline));background:color-mix(in srgb,var(--orange) 10%,var(--surface));color:var(--orange-active)}.oc-process-toggle.is-running{border-color:color-mix(in srgb,var(--orange) 40%,var(--hairline));color:var(--orange-active)}.oc-process-toggle.is-error{border-color:color-mix(in srgb,var(--red) 35%,var(--hairline));background:color-mix(in srgb,var(--red) 8%,var(--surface));color:var(--red)}.oc-process-summary{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border:1px solid var(--hairline);border-radius:var(--radius-pill);background:var(--surface);color:var(--muted);font-size:11.5px;font-weight:650;cursor:pointer;font:inherit;max-width:min(420px,100%);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.oc-process-summary:hover,.oc-process-summary.is-open{border-color:color-mix(in srgb,var(--orange) 35%,var(--hairline));color:var(--orange-active)}.oc-process-summary.is-running{color:var(--orange-active);border-color:color-mix(in srgb,var(--orange) 40%,var(--hairline));background:var(--orange-soft)}.oc-process-summary.is-error{color:var(--red);border-color:color-mix(in srgb,var(--red) 35%,var(--hairline));background:color-mix(in srgb,var(--red) 8%,var(--surface))}.oc-process-summary.is-ok{color:var(--green);border-color:color-mix(in srgb,var(--green) 30%,var(--hairline))}@media(max-width:900px){.oc-workspace.has-process .oc-process{position:absolute;top:0;right:0;bottom:0;z-index:8;width:min(360px,calc(100% - 24px));min-width:0;flex:none;margin-left:0;border-radius:var(--radius-md) 0 0 var(--radius-md);box-shadow:var(--shadow-soft)}}.oc-banner{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-3);padding:10px 14px;border:1px solid var(--hairline);border-radius:var(--radius-md);background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:var(--shadow-panel)}.oc-banner-title{display:inline-flex;align-items:center;gap:6px;font-weight:700;color:var(--ink)}.oc-banner-dot{width:8px;height:8px;border-radius:50%;background:var(--orange)}.oc-banner-body{color:var(--body);font-size:12.5px;line-height:1.45}.oc-banner-body strong{color:var(--ink)}.oc-cap-chips{display:inline-flex;flex-wrap:wrap;gap:6px;margin-left:auto;align-items:center}.oc-cap-chip{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border:1px solid color-mix(in srgb,var(--green) 30%,var(--hairline));border-radius:var(--radius-pill);background:color-mix(in srgb,var(--mint) 50%,var(--surface));color:var(--green);font-size:11.5px;font-weight:650}button.oc-cap-chip{font:inherit;cursor:pointer}.oc-tools-menu{position:relative}.oc-tools-toggle.is-active{border-color:color-mix(in srgb,var(--orange) 40%,var(--hairline));background:color-mix(in srgb,var(--orange) 10%,var(--surface));color:var(--orange-active)}.oc-tools-catalog{position:absolute;top:calc(100% + 8px);right:0;z-index:20;width:min(420px,calc(100vw - 48px));max-height:min(480px,60vh);display:flex;flex-direction:column;border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface);box-shadow:var(--shadow-soft);animation:panel-in .16s ease;overflow:hidden}.oc-tools-catalog-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--hairline);flex-shrink:0}.oc-tools-catalog-head strong{font-size:13px;color:var(--ink)}.oc-tools-catalog-meta{font-size:11.5px;color:var(--muted-soft)}.oc-tools-catalog-close{margin-left:auto;font-size:12px}.oc-tools-catalog-body{overflow-y:auto;padding:var(--space-3);display:flex;flex-direction:column;gap:var(--space-3)}.oc-tools-group-title{margin:0 0 8px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-soft)}.oc-tools-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}.oc-tools-item{padding:8px 10px;border:1px solid var(--hairline);border-radius:var(--radius-sm);background:color-mix(in srgb,var(--surface-muted) 55%,var(--surface))}.oc-tools-item-head{display:flex;align-items:center;gap:8px;margin-bottom:4px}.oc-tools-name{font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace;font-size:12.5px;font-weight:650;color:var(--ink)}.oc-tools-kind{margin-left:auto;font-size:10.5px;font-weight:650;padding:1px 7px;border-radius:var(--radius-pill);color:var(--muted);background:var(--surface);border:1px solid var(--hairline)}.oc-tools-kind.is-explore,.oc-tools-kind.is-read{color:var(--green);border-color:color-mix(in srgb,var(--green) 28%,var(--hairline));background:color-mix(in srgb,var(--mint) 45%,transparent)}.oc-tools-kind.is-mutation{color:var(--orange-active);border-color:color-mix(in srgb,var(--orange) 35%,var(--hairline));background:var(--orange-soft)}.oc-tools-kind.is-long-running{color:var(--amber);border-color:color-mix(in srgb,var(--amber) 35%,var(--hairline));background:color-mix(in srgb,var(--amber) 10%,var(--surface))}.oc-tools-desc{margin:0;font-size:12px;line-height:1.45;color:var(--body)}.oc-tools-cli{margin:4px 0 0;font-size:11px;color:var(--muted-soft)}.oc-tools-cli code{font-size:11px;color:var(--muted)}.oc-alert{padding:8px 12px;border-radius:var(--radius-md);font-size:12.5px;line-height:1.45;animation:panel-in .18s ease}.oc-alert-warning{border:1px solid color-mix(in srgb,var(--amber) 35%,var(--hairline));background:color-mix(in srgb,var(--amber) 8%,var(--surface));color:var(--amber)}.oc-alert-error{border:1px solid color-mix(in srgb,var(--red) 35%,var(--hairline));background:color-mix(in srgb,var(--red) 7%,var(--surface));color:var(--red)}.oc-messages{flex:1 1 auto;min-height:0;overflow-y:auto;padding:var(--space-4) var(--space-3);display:flex;flex-direction:column;gap:var(--space-4)}.oc-day-sep{display:flex;align-items:center;gap:var(--space-3);color:var(--muted-soft);font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;margin:var(--space-2) 0}.oc-day-sep:before,.oc-day-sep:after{content:"";flex:1;height:1px;background:var(--hairline)}.oc-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;gap:var(--space-4);text-align:center;padding:var(--space-6) var(--space-4)}.oc-empty-icon{display:grid;place-items:center;width:64px;height:64px;border-radius:50%;background:var(--orange-soft);color:var(--orange-active);font-size:30px;font-weight:800;font-family:var(--font-mono, "JetBrains Mono", monospace)}.oc-empty h3{margin:0;font-size:18px;color:var(--ink)}.oc-empty p{margin:0;max-width:46ch;color:var(--muted);font-size:13px;line-height:1.5}.oc-suggestions{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);width:100%;max-width:640px;margin-top:var(--space-2)}@media(max-width:560px){.oc-suggestions{grid-template-columns:1fr}}.oc-suggestion{appearance:none;display:flex;flex-direction:column;gap:4px;text-align:left;padding:12px 14px;border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface);color:var(--ink);cursor:pointer;transition:border-color var(--transition-fast),background var(--transition-fast),transform var(--transition-fast),box-shadow var(--transition-fast)}.oc-suggestion:hover{border-color:var(--orange);background:var(--surface);box-shadow:0 0 0 3px var(--orange-soft);transform:translateY(-1px)}.oc-suggestion strong{font-size:13px;font-weight:700}.oc-suggestion span{color:var(--muted);font-size:12px}.oc-row{display:flex;flex-direction:column;gap:4px;max-width:min(100%,900px)}.oc-row.is-user{align-self:flex-end;align-items:flex-end}.oc-row.is-assistant,.oc-row.is-tool{align-self:flex-start;align-items:stretch;width:min(100%,760px)}.oc-meta{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted-soft)}.oc-avatar{display:grid;place-items:center;width:22px;height:22px;border-radius:50%;font-size:11px;font-weight:700;flex-shrink:0}.oc-avatar-user{background:var(--ink);color:#fff}.oc-avatar-assistant{background:var(--orange);color:#fff}.oc-role{font-weight:700;color:var(--body)}.oc-time{font-variant-numeric:tabular-nums}.oc-bubble{padding:10px 14px;border-radius:var(--radius-lg);font-size:14px;line-height:1.6;overflow-wrap:anywhere;word-break:break-word;animation:bubble-in .16s ease}@keyframes bubble-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}.oc-bubble-user{background:var(--ink);color:#fff;border-bottom-right-radius:4px}.oc-bubble-assistant{background:var(--surface);color:var(--ink);border:1px solid var(--hairline);border-bottom-left-radius:4px;box-shadow:var(--shadow-panel)}.oc-bubble-tool{background:var(--canvas-soft);color:var(--body);border:1px solid var(--hairline)}.oc-bubble-error{border-color:color-mix(in srgb,var(--red) 40%,var(--hairline));background:color-mix(in srgb,var(--red) 7%,var(--surface));color:var(--red)}.oc-md{display:flex;flex-direction:column;gap:var(--space-2)}.oc-md>*:first-child{margin-top:0}.oc-md>*:last-child{margin-bottom:0}.oc-md p{margin:0}.oc-md h1,.oc-md h2,.oc-md h3,.oc-md h4{margin:var(--space-3) 0 var(--space-2);color:inherit;font-weight:700;line-height:1.3}.oc-md h1{font-size:1.3em}.oc-md h2{font-size:1.18em}.oc-md h3{font-size:1.06em}.oc-md h4{font-size:1em}.oc-md ul,.oc-md ol{margin:0;padding-left:1.4em;display:flex;flex-direction:column;gap:2px}.oc-md li{margin:0}.oc-md blockquote{margin:0;padding:2px 0 2px 12px;border-left:3px solid var(--hairline-strong);color:var(--body)}.oc-bubble-user .oc-md blockquote{border-left-color:#ffffff59;color:#ffffffd1}.oc-md code{font-size:.86em;padding:1px 5px;border-radius:4px;background:color-mix(in srgb,var(--ink) 8%,transparent)}.oc-bubble-user .oc-md code{background:#ffffff29}.oc-md pre{margin:0;padding:0;background:transparent}.oc-codeblock{margin:0;border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface-muted);overflow:hidden}.oc-bubble-assistant .oc-codeblock{background:#f3efe8}.oc-codeblock-head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:5px 6px 5px 10px;border-bottom:1px solid var(--hairline);font-size:11px;color:var(--muted);background:color-mix(in srgb,var(--ink) 4%,transparent)}.oc-codeblock-lang{font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace;font-weight:600;text-transform:lowercase}.oc-codeblock-copy{appearance:none;display:inline-flex;align-items:center;gap:5px;min-height:26px;padding:0 9px;border:1px solid transparent;border-radius:var(--radius-sm);background:transparent;color:var(--muted);font-size:11px;font-weight:600;cursor:pointer;transition:background var(--transition-fast),color var(--transition-fast),border-color var(--transition-fast)}.oc-codeblock-copy:hover{background:var(--surface);color:var(--ink);border-color:var(--hairline-strong)}.oc-codeblock pre{margin:0;padding:var(--space-3) var(--space-4);overflow-x:auto;font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace;font-size:12.5px;line-height:1.55;white-space:pre;color:var(--ink)}.oc-md a{color:var(--orange);text-decoration:none;border-bottom:1px solid color-mix(in srgb,var(--orange) 40%,transparent)}.oc-md a:hover{border-bottom-color:var(--orange)}.oc-md hr{margin:var(--space-2) 0;border:none;border-top:1px solid var(--hairline-strong)}.oc-stream{white-space:pre-wrap;font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace;font-size:13px;line-height:1.55}.oc-cursor{display:inline-block;width:7px;height:1.05em;margin-left:2px;vertical-align:text-bottom;background:var(--orange);animation:oc-blink 1s steps(2,start) infinite;border-radius:1px}@keyframes oc-blink{to{opacity:0}}.oc-tool{border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface);overflow:hidden;box-shadow:var(--shadow-panel);animation:panel-in .16s ease}.oc-tool.is-error{border-color:color-mix(in srgb,var(--red) 38%,var(--hairline))}.oc-tool.is-running{border-color:color-mix(in srgb,var(--orange) 40%,var(--hairline))}.oc-tool-head{display:flex;align-items:center;gap:var(--space-2);width:100%;padding:9px 12px;border:none;background:transparent;text-align:left;cursor:pointer;font:inherit;color:var(--ink)}.oc-tool-head:hover{background:var(--surface-muted)}.oc-tool-chevron{flex-shrink:0;width:12px;height:12px;transition:transform var(--transition-fast)}.oc-tool-head[aria-expanded=true] .oc-tool-chevron{transform:rotate(90deg)}.oc-tool-icon{display:grid;place-items:center;width:22px;height:22px;border-radius:6px;background:var(--surface-muted);color:var(--orange-active);flex-shrink:0}.oc-tool-name{font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace;font-size:12.5px;font-weight:650;flex-shrink:0}.oc-tool-args{color:var(--muted);font-size:11.5px;font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:8px;min-width:0}.oc-tool-status{margin-left:auto;flex-shrink:0;font-size:11px;font-weight:650;padding:2px 8px;border-radius:var(--radius-pill)}.oc-tool-waiting{color:var(--muted);font-size:12px;animation:pulse 1.5s infinite}.oc-tool-status.is-running{color:var(--orange-active);background:var(--orange-soft)}.oc-tool-status.is-ok{color:var(--green);background:color-mix(in srgb,var(--mint) 60%,transparent)}.oc-tool-status.is-error{color:var(--red);background:color-mix(in srgb,var(--red) 10%,transparent)}.oc-tool-body{padding:0 var(--space-4) var(--space-3);border-top:1px solid var(--hairline)}.oc-tool-section{display:flex;flex-direction:column;gap:4px;padding-top:var(--space-3)}.oc-tool-label{font-size:11px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;color:var(--muted-soft)}.oc-tool-code{margin:0;padding:var(--space-3);border-radius:var(--radius-sm);background:var(--surface-muted);font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:260px;overflow-y:auto;color:var(--body)}.oc-composer{flex-shrink:0;border-top:1px solid var(--hairline);padding:var(--space-3) var(--space-4) var(--space-4);background:color-mix(in srgb,var(--canvas) 86%,transparent)}.oc-composer-inner{display:flex;flex-direction:column;gap:var(--space-2);max-width:1080px;margin:0 auto}.oc-composer-shell{position:relative;display:flex;align-items:flex-end;gap:var(--space-2);padding:8px 8px 8px 14px;border:1px solid var(--hairline-strong);border-radius:var(--radius-lg);background:var(--surface);box-shadow:var(--shadow-panel);transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.oc-composer-shell:focus-within{border-color:var(--orange);box-shadow:0 0 0 3px var(--orange-soft)}.oc-composer-shell.is-busy{opacity:.78}.oc-textarea{flex:1 1 auto;min-width:0;min-height:52px;max-height:280px;padding:8px 0;border:none;background:transparent;color:var(--ink);font:inherit;font-size:14px;line-height:1.5;resize:none;outline:none}.oc-textarea::placeholder{color:var(--muted-soft)}.oc-send{appearance:none;display:grid;place-items:center;width:38px;height:38px;flex-shrink:0;padding:0;border:1px solid var(--orange);border-radius:50%;background:var(--orange);color:#fff;cursor:pointer;transition:background var(--transition-fast),border-color var(--transition-fast),transform var(--transition-fast)}.oc-send:hover:not(:disabled){background:var(--orange-active);border-color:var(--orange-active)}.oc-send:active:not(:disabled){transform:scale(.94)}.oc-send:disabled{opacity:.4;cursor:not-allowed}.oc-stop{appearance:none;display:grid;place-items:center;width:38px;height:38px;flex-shrink:0;padding:0;border:1px solid var(--hairline-strong);border-radius:50%;background:var(--surface);color:var(--red);cursor:pointer;transition:background var(--transition-fast),border-color var(--transition-fast)}.oc-stop:hover{background:color-mix(in srgb,var(--red) 8%,var(--surface));border-color:var(--red)}.oc-composer-foot{display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;font-size:11.5px;color:var(--muted)}.oc-composer-hint{display:inline-flex;align-items:center;gap:5px}.oc-kbd{display:inline-block;padding:1px 6px;border:1px solid var(--hairline-strong);border-bottom-width:2px;border-radius:4px;background:var(--surface);color:var(--body);font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace;font-size:10.5px;line-height:1.4}.oc-session-info{display:inline-flex;align-items:center;gap:8px;margin-left:auto;font-size:11px;color:var(--muted-soft)}.oc-session-info code{font-size:11px;color:var(--muted)}.oc-new-session{margin-left:auto}@media(max-width:600px){.oc-session-info{width:100%;margin-left:0}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Loop 操作台 · Operator Console</title>
7
- <script type="module" crossorigin src="/assets/index-CUDke82y.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-wSEksVSO.css">
7
+ <script type="module" crossorigin src="/assets/index-DVl7Jxt5.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-lVcIr9Ju.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  * `fullstack-v1` is NOT a new DAG or taskKind. It is `agent-worker`'s
6
6
  * deterministic structural validation + read-model projection policy over a
7
7
  * Feature Packet. See
8
- * `docs/design/agent-worker-fullstack-workflow-integration.md` §9.
8
+ * `docs/design/active/agent-worker-fullstack-workflow-integration.md` §9.
9
9
  *
10
10
  * A Feature Packet declares its profile via an optional `feature.yaml` next to
11
11
  * `acceptance.yaml`. A packet without `feature.yaml` (or one that does not set
@@ -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 {
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
5
+ import { isOpenspecSpecFilePath, isOpenspecSpecPath, isOpenspecSpecSearchTarget, } from "../../shared/openspec-spec.js";
5
6
  const DAG_RUN_LIFECYCLE_DIRS = ["active", "completed", "paused"];
6
7
  /**
7
8
  * Known knowledge-base connector tool names.
@@ -18,7 +19,8 @@ const KB_CONNECTOR_TOOLS = new Set([
18
19
  ]);
19
20
  /**
20
21
  * Pattern for detecting spec-related files:
21
- * - openspec/** files
22
+ * - openspec files (generic evidence, including non-normative paths)
23
+ * - ai_workspace files
22
24
  * - *.spec.md / *.spec.ts / *.spec.tsx
23
25
  * - project-specs/**
24
26
  * - design-spec.md, code-standards.md, review-checklist.md, etc.
@@ -27,6 +29,7 @@ const KB_CONNECTOR_TOOLS = new Set([
27
29
  */
28
30
  const SPEC_FILE_PATTERNS = [
29
31
  /openspec\//i,
32
+ /ai_workspace\//i,
30
33
  /\/project-specs\//i,
31
34
  /\/spec\//i,
32
35
  /\.spec\.(md|tsx?|jsx?)$/i,
@@ -47,22 +50,16 @@ function isSpecFilePath(filePath) {
47
50
  function isKnowledgeBaseTool(toolName) {
48
51
  return KB_CONNECTOR_TOOLS.has(toolName);
49
52
  }
50
- /** A repo-relative path references <repoRoot>/openspec/**. */
53
+ /** A repo-relative path references a canonical openspec spec directory. */
51
54
  function isOpenspecPath(filePath) {
52
- const normalized = filePath.replaceAll(path.sep, "/");
53
- return normalized === "openspec" || normalized.startsWith("openspec/");
55
+ return isOpenspecSpecFilePath(filePath.replaceAll(path.sep, "/"));
54
56
  }
55
- /** A search query targets the openspec/ directory. */
57
+ /** A search query targets a canonical openspec spec directory. */
56
58
  function isOpenspecSearch(query, searchPath) {
57
- const lower = query.toLowerCase();
58
- const normalizedPath = searchPath?.replaceAll(path.sep, "/").toLowerCase();
59
- return (normalizedPath === "openspec" ||
60
- normalizedPath?.startsWith("openspec/") === true ||
61
- lower === "openspec" ||
62
- lower === "openspec/" ||
63
- lower.startsWith("openspec/") ||
64
- lower.includes("openspec/**") ||
65
- lower.includes("openspec/*"));
59
+ const normalizedPath = searchPath?.replaceAll(path.sep, "/");
60
+ if (normalizedPath && isOpenspecSpecPath(normalizedPath))
61
+ return true;
62
+ return isOpenspecSpecSearchTarget(query);
66
63
  }
67
64
  function resolveSessionEventsPath(repoRoot, dagRunId, nodeId) {
68
65
  if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
@@ -362,7 +359,7 @@ export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
362
359
  if (status === "no-evidence") {
363
360
  summaryLines.push("未观察到任何规范证据:无 skill 注入、无文件读取、无检索操作。");
364
361
  }
365
- // Separate openspec/** reads and searches for Dashboard display.
362
+ // Separate openspec spec directory reads and searches for Dashboard display.
366
363
  // Knowledge base and openspec are parallel sources.
367
364
  const openspecReads = specReads.filter((r) => isOpenspecPath(r.path));
368
365
  const openspecSearches = searches.filter((s) => isOpenspecSearch(s.query, s.path));
@@ -46,6 +46,28 @@ export function getActiveNodes(dag) {
46
46
  return (dag.nodes ?? []).filter((n) => isNodeActive(n.status));
47
47
  }
48
48
 
49
+ export function frontendLintSummary(frontendLint) {
50
+ if (!frontendLint) return null;
51
+ const statusLabels = {
52
+ passed: "passed",
53
+ "baseline-debt": "baseline-debt",
54
+ failed: "failed",
55
+ unavailable: "unavailable",
56
+ };
57
+ const status = statusLabels[frontendLint.status] ?? "unavailable";
58
+ const changed = frontendLint.writerChangedFiles?.length ?? 0;
59
+ const tolerated = frontendLint.toleratedDiagnosticCount ?? 0;
60
+ const blocked = frontendLint.blockingDiagnosticCount ?? 0;
61
+ const reason = (frontendLint.blockingReasons ?? []).join(";");
62
+ return [
63
+ status,
64
+ `修改文件 ${changed}`,
65
+ `容忍存量诊断 ${tolerated}`,
66
+ `阻断诊断 ${blocked}`,
67
+ ...(reason ? [`原因:${reason}`] : []),
68
+ ].join(" · ");
69
+ }
70
+
49
71
  export function dagSortTime(dag) {
50
72
  return dag.startedAt ?? dag.finishedAt ?? "";
51
73
  }
@@ -79,6 +79,7 @@ import {
79
79
  } from "../shell-chrome.js";
80
80
  import {
81
81
  dagProgress,
82
+ frontendLintSummary,
82
83
  isNodeFailed,
83
84
  isNodeFinished,
84
85
  isNodeActive,
@@ -223,6 +224,10 @@ export async function renderDagDetail(dagRunId, initial = true) {
223
224
  ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
224
225
  ["DAG 路径", dag.dagPath ?? "—"],
225
226
  );
227
+ const lintSummary = frontendLintSummary(dag.frontendLint);
228
+ if (lintSummary) {
229
+ primaryEntries.push(["前端 lint 判定", lintSummary]);
230
+ }
226
231
  const primaryGrid = metaGrid(primaryEntries);
227
232
  const diagnosticGrid = metaGrid([
228
233
  ["当前判定", dagStatusBadge(dag.effectiveStatus ?? dag.status)],
@@ -763,7 +763,44 @@ function advisoryBadge(status) {
763
763
  const className = status === "PASS" ? "passed" : status === "FAIL" ? "failure" : "skipped";
764
764
  return `<span class="status ${className}">${status === "Unavailable" ? "不可用" : status}</span>`;
765
765
  }
766
+ function conclusionIcon(failed) {
767
+ return failed
768
+ ? `<svg class="conclusion-icon" viewBox="0 0 48 48" role="img" aria-label="测试未通过"><circle cx="24" cy="24" r="20"/><path d="M24 14v13"/><circle cx="24" cy="34" r="1.5" class="icon-dot"/></svg>`
769
+ : `<svg class="conclusion-icon" viewBox="0 0 48 48" role="img" aria-label="测试通过"><circle cx="24" cy="24" r="20"/><path d="m14 24 7 7 14-15"/></svg>`;
770
+ }
771
+ function reportDetailSection(title, content, open = false) {
772
+ return `<details class="report-detail"${open ? " open" : ""}><summary>${escapeHtml(title)}</summary><div class="report-detail-body">${content}</div></details>`;
773
+ }
766
774
  export function renderBackendTestHtml(input) {
775
+ const failed = input.parsed.failed + input.parsed.errors > 0;
776
+ const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
777
+ const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
778
+ const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
779
+ const traceability = parseAdvisorySummary(input.traceabilitySummary);
780
+ const failures = input.parsed.cases.filter((result) => result.status !== "passed");
781
+ const rows = input.parsed.cases.map((result) => {
782
+ const caseId = junitCaseId(result.name) ?? "未关联";
783
+ const item = catalog.get(caseId);
784
+ const io = requestResponseSummary(result);
785
+ const failureDetails = result.status === "passed"
786
+ ? ""
787
+ : `<div class="failure-detail"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? reportDetailSection("失败详情", `<pre>${escapeHtml(result.details)}</pre>`) : ""}</div>`;
788
+ return `<article class="case-card result-${result.status}"><div class="case-head"><div><span class="case-id">${escapeHtml(caseId)}</span><h3>${escapeHtml(item?.title ?? result.name)}</h3><p>${escapeHtml(item?.scenario ?? "未从 Markdown 用例提取场景说明。")}</p></div><div class="case-outcome"><span class="status ${result.status}">${humanStatus(result.status)}</span><span class="duration">${formatDuration(result.durationMs)}</span></div></div><div class="case-meta"><span>自动化用例</span><code>${escapeHtml(result.name)}</code></div><div class="io-grid">${reportDetails("接口请求参数", io.requests, "本次 JUnit 未捕获请求日志。")}${reportDetails("接口响应结果", io.responses, "本次 JUnit 未捕获响应日志。")}</div>${failureDetails}</article>`;
789
+ }).join("");
790
+ const failureOverview = failures.length > 0
791
+ ? `<div class="failure-list">${failures.map((result) => {
792
+ const caseId = junitCaseId(result.name) ?? "未关联";
793
+ return `<article><div>${advisoryBadge(result.status === "skipped" ? "Unavailable" : "FAIL")}<strong>${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</strong></div><p>${escapeHtml(result.message || humanStatus(result.status))}</p></article>`;
794
+ }).join("")}</div>`
795
+ : '<div class="empty-state">本轮没有失败、错误或跳过用例。</div>';
796
+ const conclusion = failed
797
+ ? `本轮测试未通过:${input.parsed.failed + input.parsed.errors} 条用例失败或错误。`
798
+ : `本轮测试通过:${input.parsed.passed} 条用例执行成功。`;
799
+ const executionOverview = `<div class="metric-grid"><div><span>用例总数</span><strong>${input.parsed.tests}</strong></div><div><span>通过</span><strong>${input.parsed.passed}</strong></div><div><span>失败 / 错误</span><strong>${input.parsed.failed} / ${input.parsed.errors}</strong></div><div><span>跳过</span><strong>${input.parsed.skipped}</strong></div><div><span>通过率</span><strong>${(passRate * 100).toFixed(2)}%</strong></div><div><span>总耗时</span><strong>${formatDuration(input.parsed.durationMs)}</strong></div></div>`;
800
+ const qualityDetails = `<div class="quality-grid"><div class="quality-card"><header><strong>Markdown 用例校验</strong>${advisoryBadge(caseValidation.status)}</header><p>Findings:${caseValidation.findings ?? "不可用"}</p><p>${escapeHtml(caseValidation.firstFinding)}</p></div><div class="quality-card"><header><strong>Markdown → pytest 追溯</strong>${advisoryBadge(traceability.status)}</header><p>Findings:${traceability.findings ?? "不可用"}</p><p>${escapeHtml(traceability.firstFinding)}</p></div></div><div class="evidence-line"><span>执行环境</span>${escapeHtml(input.environmentSummary || "不可用")}</div>`;
801
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title><style>:root{color-scheme:light;--ink:#182230;--muted:#667085;--line:#dce3ec;--panel:#f6f8fb;--brand:#163a63;--accent:#2e6da4;--pass:#067647;--pass-bg:#ecfdf3;--fail:#b42318;--fail-bg:#fef3f2;--skip:#9a6700;--skip-bg:#fffaeb;--shadow:0 18px 45px rgba(28,55,90,.09)}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,#dcecff 0,transparent 32rem),linear-gradient(180deg,#edf3fa 0,#f8fafc 34rem);color:var(--ink);font-family:Inter,system-ui,"Microsoft YaHei","PingFang SC",sans-serif;line-height:1.65}header,main{max-width:1280px;margin:auto}header{padding:3.5rem 2rem 1.75rem}main{padding:0 2rem 4rem}h1{margin:.3rem 0;color:var(--brand);font-size:clamp(2rem,4vw,3rem);letter-spacing:-.03em}h2{margin:0 0 1rem;color:var(--brand)}h3{margin:.35rem 0 .15rem;font-size:1.12rem}.eyebrow{color:var(--accent);font-weight:850;letter-spacing:.12em}.subtitle,.muted,.case-head p{color:var(--muted)}section,.report-detail{background:rgba(255,255,255,.94);border:1px solid rgba(220,227,236,.95);border-radius:1.15rem;padding:1.5rem;margin-top:1rem;box-shadow:var(--shadow)}.conclusion{display:flex;align-items:center;gap:1rem;padding:1rem 1.25rem;border-left:5px solid ${failed ? "var(--fail)" : "var(--pass)"};border-radius:.8rem;background:${failed ? "var(--fail-bg)" : "var(--pass-bg)"};color:${failed ? "var(--fail)" : "var(--pass)"}}.conclusion-icon{width:4rem;height:4rem;flex:0 0 4rem;display:block;overflow:visible}.conclusion-icon circle:first-child{fill:currentColor;fill-opacity:.12;stroke:currentColor;stroke-width:2}.conclusion-icon path{fill:none;stroke:currentColor;stroke-width:3.2;stroke-linecap:round;stroke-linejoin:round}.conclusion-icon .icon-dot{fill:currentColor;stroke:none}.conclusion-copy{min-width:0}.conclusion-copy strong{display:block;font-size:1.05rem}.conclusion-copy span{display:block;font-size:.9rem;font-weight:400}.metric-grid{display:grid;grid-template-columns:repeat(6,minmax(7rem,1fr));gap:.75rem;margin-top:1rem}.metric-grid>div{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.metric-grid span,.metric-grid small{display:block;color:var(--muted);font-size:.88rem}.metric-grid strong{display:block;font-size:1.45rem;font-weight:700}.quality-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.quality-card{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.quality-card header{display:flex;align-items:center;justify-content:space-between;padding:0;margin:0 0 .5rem}.quality-card p{margin:.25rem 0;color:var(--muted)}.status{display:inline-flex;align-items:center;padding:.18rem .62rem;border-radius:999px;font-weight:800;font-size:.86rem}.status.passed{color:var(--pass);background:var(--pass-bg)}.status.failure,.status.error{color:var(--fail);background:var(--fail-bg)}.status.skipped{color:var(--skip);background:var(--skip-bg)}.failure-list{display:grid;gap:.75rem}.failure-list article{border-left:4px solid var(--fail);background:var(--fail-bg);padding:.85rem 1rem;border-radius:.65rem}.empty-state{padding:1rem;border:1px dashed var(--line);border-radius:.75rem;color:var(--muted);background:var(--panel)}.case-list{display:grid;gap:1rem}.case-card{border:1px solid var(--line);border-radius:1rem;padding:1.15rem;background:linear-gradient(145deg,#fff,#fbfcfe);box-shadow:0 8px 24px rgba(28,55,90,.06)}.case-card.result-failure,.case-card.result-error{border-left:5px solid var(--fail)}.case-card.result-passed{border-left:5px solid var(--pass)}.case-head{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start}.case-head p{margin:.2rem 0}.case-id{display:inline-block;color:var(--accent);font-weight:850;letter-spacing:.04em}.case-outcome{display:flex;align-items:center;gap:.75rem;white-space:nowrap}.duration{color:var(--muted);font-variant-numeric:tabular-nums}.case-meta{display:flex;gap:.75rem;align-items:center;margin:.85rem 0;padding:.65rem .8rem;border-radius:.7rem;background:var(--panel);color:var(--muted)}code{color:var(--brand);font-weight:700}.io-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.io-details{border:1px solid var(--line);border-radius:.75rem;padding:.7rem;background:white}.io-details summary,.report-detail summary{cursor:pointer;color:var(--brand);font-weight:800}.io-details summary span{float:right;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:18rem;overflow:auto;background:#101828;color:#eef4ff;padding:.85rem;border-radius:.6rem;font-size:.82rem}.failure-detail{margin-top:.8rem;padding:.8rem;border-radius:.7rem;background:var(--fail-bg);color:var(--fail)}.report-detail{padding:0;overflow:hidden}.report-detail summary{list-style:none;padding:1rem 1.25rem}.report-detail summary::-webkit-details-marker{display:none}.report-detail summary::after{content:"+";float:right;font-size:1.2rem;font-weight:400;color:var(--muted)}.report-detail[open] summary::after{content:"−"}.report-detail-body{border-top:1px solid var(--line);padding:1.25rem}.evidence-line{display:flex;gap:.75rem;align-items:baseline;padding:.7rem .8rem;background:var(--panel);border-radius:.65rem;font-size:.9rem}.evidence-line span{color:var(--muted);min-width:8rem}.subtitle{margin:.2rem 0 0}@media(max-width:900px){.metric-grid{grid-template-columns:repeat(2,1fr)}.quality-grid,.io-grid{grid-template-columns:1fr}.case-head{display:block}.case-outcome{margin-top:.65rem}header,main{padding-left:1rem;padding-right:1rem}}@media(max-width:420px){.conclusion{align-items:flex-start}.conclusion-icon{width:3rem;height:3rem;flex-basis:3rem}.metric-grid{grid-template-columns:1fr 1fr}}</style></head><body><header><div class="eyebrow">后端自动化测试报告</div><h1>${escapeHtml(input.title)}</h1><p class="subtitle">测试结论、质量状态与每条接口用例的请求/响应事实均来自同一次 pytest 执行。</p></header><main><section><h2>当前结论</h2><div class="conclusion">${conclusionIcon(failed)}<div class="conclusion-copy"><strong>${escapeHtml(failed ? "测试未通过" : "测试通过")}</strong><span>${escapeHtml(conclusion)}</span></div></div>${executionOverview}</section>${reportDetailSection("质量校验", qualityDetails, true)}${reportDetailSection("失败概览", failureOverview, failures.length > 0)}${reportDetailSection("用例执行结果", `<div class="case-list">${rows || '<div class="empty-state">未发现可展示的 JUnit testcase。</div>'}</div>`, true)}</main></body></html>`;
802
+ }
803
+ function renderBackendTestHtmlLegacy(input) {
767
804
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
768
805
  const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
769
806
  const rows = input.parsed.cases.map((result) => {
@@ -1,11 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
3
+ import { readFile, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { z } from "zod";
7
7
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
8
8
  import { findPackageRoot } from "../../shared/package-metadata.js";
9
+ import { pathMatchesPattern } from "../../shared/git-progress.js";
9
10
  export const FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID = "frontend-implementation-contract-v1";
10
11
  /**
11
12
  * Load the canonical frontend-implementation-contract-v1 JSON Schema from the
@@ -122,7 +123,7 @@ export const frontendImplementationContractSchema = z
122
123
  verificationTargetIds: z.array(z.string().min(1)),
123
124
  evidenceGap: gap.optional(),
124
125
  })
125
- .strict()),
126
+ .strict()).min(1),
126
127
  uiStates: z.array(z
127
128
  .object({
128
129
  name: z.string().min(1),
@@ -132,7 +133,7 @@ export const frontendImplementationContractSchema = z
132
133
  verificationTargetIds: z.array(z.string().min(1)).optional(),
133
134
  notApplicableReason: z.string().min(1).optional(),
134
135
  })
135
- .strict()),
136
+ .strict()).min(1),
136
137
  interactions: z.array(z
137
138
  .object({
138
139
  name: z.string().min(1),
@@ -185,7 +186,7 @@ export const frontendImplementationContractSchema = z
185
186
  requirementIds: z.array(id),
186
187
  uiStates: z.array(z.string().min(1)),
187
188
  })
188
- .strict()),
189
+ .strict()).min(1),
189
190
  evidenceGaps: z.array(gap),
190
191
  })
191
192
  .strict()
@@ -198,6 +199,30 @@ export const frontendImplementationContractSchema = z
198
199
  path: ["verificationTargets"],
199
200
  });
200
201
  const known = new Set(value.sourceBinding.requirementIds);
202
+ const stateNames = new Set(value.uiStates.map((state) => state.name));
203
+ for (const target of value.verificationTargets) {
204
+ // Verification evidence may point to read-only project files such as
205
+ // tsconfig.json or a test entrypoint; implementation targets remain
206
+ // governed by targets.files and the writer writeSet.
207
+ for (const requirementId of target.requirementIds) {
208
+ if (!known.has(requirementId)) {
209
+ ctx.addIssue({
210
+ code: "custom",
211
+ message: `verification target references unknown requirement ${requirementId}`,
212
+ path: ["verificationTargets"],
213
+ });
214
+ }
215
+ }
216
+ for (const stateName of target.uiStates) {
217
+ if (!stateNames.has(stateName)) {
218
+ ctx.addIssue({
219
+ code: "custom",
220
+ message: `verification target references unknown UI state ${stateName}`,
221
+ path: ["verificationTargets"],
222
+ });
223
+ }
224
+ }
225
+ }
201
226
  for (const requirement of value.requirements) {
202
227
  if (!known.has(requirement.id))
203
228
  ctx.addIssue({
@@ -238,7 +263,70 @@ export const frontendImplementationContractSchema = z
238
263
  path: ["uiStates"],
239
264
  });
240
265
  }
266
+ if (value.mockApi.strategy !== "not-needed") {
267
+ if (value.mockApi.endpoints.length === 0) {
268
+ ctx.addIssue({
269
+ code: "custom",
270
+ message: "Mock strategy requires at least one endpoint",
271
+ path: ["mockApi", "endpoints"],
272
+ });
273
+ }
274
+ value.mockApi.endpoints.forEach((endpoint, index) => {
275
+ if (!endpoint.fixture) {
276
+ ctx.addIssue({
277
+ code: "custom",
278
+ message: "Mock endpoint requires a fixture path",
279
+ path: ["mockApi", "endpoints", index, "fixture"],
280
+ });
281
+ }
282
+ if (!endpoint.consumer) {
283
+ ctx.addIssue({
284
+ code: "custom",
285
+ message: "Mock endpoint requires a consumer path",
286
+ path: ["mockApi", "endpoints", index, "consumer"],
287
+ });
288
+ }
289
+ for (const [field, file] of [["fixture", endpoint.fixture], ["consumer", endpoint.consumer]]) {
290
+ if (file && !value.targets.files.some((pattern) => pathMatchesPattern(file, pattern))) {
291
+ ctx.addIssue({
292
+ code: "custom",
293
+ message: `Mock ${field} is outside contract targets: ${file}`,
294
+ path: ["mockApi", "endpoints", index, field],
295
+ });
296
+ }
297
+ }
298
+ });
299
+ }
241
300
  });
301
+ export async function assertFrontendSourceBindingFresh(input) {
302
+ for (const source of input.binding.sources) {
303
+ // DAG source bindings are task-relative (for example source/需求.md),
304
+ // while older bindings may already contain the repository-relative
305
+ // .harness/tasks/<taskId>/ prefix. Resolve both forms before hashing.
306
+ const taskDir = path.resolve(input.workspaceRoot, ".harness", "tasks", input.binding.taskId);
307
+ const absolute = source.path.startsWith(".harness/")
308
+ ? path.resolve(input.workspaceRoot, source.path)
309
+ : path.resolve(taskDir, source.path);
310
+ const relative = path.relative(input.workspaceRoot, absolute);
311
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
312
+ throw new Error(`frontend source binding escapes workspace: ${source.path}`);
313
+ }
314
+ let content;
315
+ try {
316
+ content = await readFile(absolute);
317
+ const info = await stat(absolute);
318
+ if (!info.isFile())
319
+ throw new Error("not a regular file");
320
+ }
321
+ catch (error) {
322
+ throw new Error(`frontend source binding file unavailable: ${source.path}: ${error instanceof Error ? error.message : String(error)}`);
323
+ }
324
+ const actual = createHash("sha256").update(content).digest("hex");
325
+ if (actual !== source.sha256) {
326
+ throw new Error(`frontend source binding is stale: ${source.path}`);
327
+ }
328
+ }
329
+ }
242
330
  const SECRET_KEY = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|credential|authorization)/i;
243
331
  const SECRET_VALUE = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|ghp|github_pat|xox[baprs]|AKIA)[-_A-Za-z0-9]{12,}\b)/;
244
332
  function secretIssues(value, at = "$", issues = []) {
@@ -314,9 +402,42 @@ function looksLikeStrictFrontendContract(value) {
314
402
  * Near-schema payloads are left untouched so unknown-key fail-closed still holds.
315
403
  */
316
404
  export function coerceFrontendImplementationContractInput(value, canonicalBinding) {
317
- if (looksLikeStrictFrontendContract(value))
318
- return value;
319
- const record = asRecord(value);
405
+ const rawRecord = asRecord(value);
406
+ const verificationTargetIds = rawRecord && Array.isArray(rawRecord.verificationTargets)
407
+ ? new Set(rawRecord.verificationTargets
408
+ .map((item) => asString(asRecord(item)?.id))
409
+ .filter(Boolean))
410
+ : undefined;
411
+ const normalizedValue = rawRecord
412
+ ? {
413
+ ...rawRecord,
414
+ requirements: verificationTargetIds && Array.isArray(rawRecord.requirements)
415
+ ? rawRecord.requirements.map((item) => {
416
+ const requirement = asRecord(item);
417
+ if (!requirement || !Array.isArray(requirement.verificationTargetIds))
418
+ return item;
419
+ return {
420
+ ...requirement,
421
+ verificationTargetIds: requirement.verificationTargetIds.filter((id) => typeof id === "string" && verificationTargetIds.has(id)),
422
+ };
423
+ })
424
+ : rawRecord.requirements,
425
+ uiStates: Array.isArray(rawRecord.uiStates)
426
+ ? rawRecord.uiStates.map((item) => {
427
+ const state = asRecord(item);
428
+ if (!state ||
429
+ state.applicable === false ||
430
+ (state.notApplicableReason !== "" && state.notApplicableReason !== null))
431
+ return item;
432
+ const { notApplicableReason: _emptyReason, ...withoutEmptyReason } = state;
433
+ return withoutEmptyReason;
434
+ })
435
+ : rawRecord.uiStates,
436
+ }
437
+ : value;
438
+ if (looksLikeStrictFrontendContract(normalizedValue))
439
+ return normalizedValue;
440
+ const record = asRecord(normalizedValue);
320
441
  if (!record)
321
442
  return value;
322
443
  const implementation = asRecord(record.implementation);
@@ -333,7 +454,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
333
454
  ...asStringArray(asRecord(component?.paths)?.domHelper ? [asRecord(component?.paths)?.domHelper] : []),
334
455
  ].filter((item, index, arr) => arr.indexOf(item) === index);
335
456
  if (targetFiles.length === 0) {
336
- targetFiles.push("apps/web/src/welcome/WelcomeBanner.js");
457
+ throw new Error("frontend contract compatibility input must declare target files");
337
458
  }
338
459
  const riskRaw = asString(record.riskLevel) ||
339
460
  asString(record.risk) ||
@@ -383,21 +504,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
383
504
  });
384
505
  }
385
506
  if (verificationTargets.length === 0) {
386
- verificationTargets.push({
387
- id: "VT-STATIC",
388
- type: "static",
389
- commandLabel: "npm run typecheck",
390
- file: targetFiles[0],
391
- requirementIds: [...canonicalBinding.requirementIds],
392
- uiStates: ["success"],
393
- }, {
394
- id: "VT-UNIT",
395
- type: "unit",
396
- commandLabel: "npm run test:unit:fe",
397
- file: targetFiles.find((p) => p.includes("__tests__")) || targetFiles[0],
398
- requirementIds: [...canonicalBinding.requirementIds],
399
- uiStates: ["success", "error"],
400
- });
507
+ throw new Error("frontend contract compatibility input must declare verification targets");
401
508
  }
402
509
  const defaultVerificationIds = verificationTargets.map((item) => String(item.id));
403
510
  const requirements = [];
@@ -440,11 +547,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
440
547
  }
441
548
  for (const id of canonicalBinding.requirementIds) {
442
549
  if (!requirements.some((item) => item.id === id)) {
443
- requirements.push({
444
- id,
445
- implementationTargets: targetFiles,
446
- verificationTargetIds: defaultVerificationIds,
447
- });
550
+ throw new Error(`frontend contract compatibility input does not cover ${id}`);
448
551
  }
449
552
  }
450
553
  const uiStates = [];
@@ -511,14 +614,20 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
511
614
  });
512
615
  }
513
616
  const strategyRaw = asString(mockApiIn.strategy) || "not-needed";
514
- const strategy = [
617
+ const allowedStrategies = [
515
618
  "native",
516
619
  "browser-intercept",
517
620
  "request-adapter",
518
621
  "not-needed",
519
- ].includes(strategyRaw)
520
- ? strategyRaw
521
- : "not-needed";
622
+ ];
623
+ if (!allowedStrategies.includes(strategyRaw)) {
624
+ throw new Error(`frontend contract compatibility input has unsupported Mock strategy: ${strategyRaw}`);
625
+ }
626
+ const strategy = strategyRaw;
627
+ const productionDefaultOff = mockApiIn.productionDefaultOff === true || mockApiIn.productionMockOff === true;
628
+ if (!productionDefaultOff) {
629
+ throw new Error("frontend contract compatibility input must prove production Mock is off");
630
+ }
522
631
  const activation = asString(mockApiIn.activation) ||
523
632
  (strategy === "not-needed"
524
633
  ? "production remains real fetch; unit tests may inject fetchImpl only"