@tea-agent/loop-agent 0.20.0 → 0.20.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 (62) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/application/dag/args.js +29 -0
  3. package/dist/application/dag/run-dag.js +3 -1
  4. package/dist/cli/command-definitions.js +15 -1
  5. package/dist/cli/program.js +11 -1
  6. package/dist/commands/dag-rerun-task.js +19 -0
  7. package/dist/commands/dag-rerun.js +111 -0
  8. package/dist/executors/shell-executor.js +1 -0
  9. package/dist/shared/operator/capabilities.js +54 -0
  10. package/dist/worker/console/index.js +1 -1
  11. package/dist/worker/console/inspect-split.js +82 -0
  12. package/dist/worker/console/operation-runner.js +3 -1
  13. package/dist/worker/console/operation-store.js +1 -0
  14. package/dist/worker/console/operator-actions.js +153 -2
  15. package/dist/worker/console/operator-user-error.js +10 -0
  16. package/dist/worker/console/pi-readiness.js +4 -0
  17. package/dist/worker/console/recovery-cta.js +116 -5
  18. package/dist/worker/console/recovery-selection.js +107 -0
  19. package/dist/worker/console/resolve-dag-run-for-task.js +50 -11
  20. package/dist/worker/console/routes.js +20 -0
  21. package/dist/worker/console/sibling-controller.js +12 -7
  22. package/dist/worker/console/static/assets/index-CUDke82y.js +18 -0
  23. package/dist/worker/console/static/assets/index-wSEksVSO.css +1 -0
  24. package/dist/worker/console/static/index.html +2 -2
  25. package/dist/worker/observability/read-model.js +60 -0
  26. package/dist/worker/observe/spec-evidence.js +33 -0
  27. package/dist/worker/observe/static/index.html +1 -1
  28. package/dist/worker/observe/static/views/dag-inspector.js +67 -4
  29. package/dist/worker/run-task/run-task.js +7 -0
  30. package/dist/workflows/dag/frontend-prewrite-gate.js +97 -2
  31. package/dist/workflows/dag/frontend-project-capability.js +6 -2
  32. package/dist/workflows/dag/frontend-test-result-contract.js +64 -0
  33. package/dist/workflows/dag/init-hybrid.js +91 -48
  34. package/dist/workflows/dag/node-execution.js +40 -6
  35. package/dist/workflows/dag/output-protocol.js +76 -0
  36. package/dist/workflows/dag/rerun-plan.js +611 -0
  37. package/dist/workflows/dag/rerun-run.js +497 -0
  38. package/dist/workflows/dag/rerun-task.js +284 -0
  39. package/dist/workflows/dag/retry-policy.js +20 -1
  40. package/dist/workflows/dag/runner.js +50 -0
  41. package/dist/workflows/dag/skill-snapshot.js +22 -3
  42. package/dist/workflows/dag/types.js +10 -0
  43. package/dist/workflows/dag/validate.js +11 -0
  44. package/dist/workflows/dag/workspace-checkpoint.js +163 -0
  45. package/docs/README.md +1 -0
  46. package/docs/templates/agent-dag.schema.json +17 -2
  47. package/docs/templates/frontend-test-case-checklist.md +16 -1
  48. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +26 -2
  49. package/docs/templates/frontend-test-dag.json +65 -6
  50. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +4 -1
  51. package/package.json +1 -1
  52. package/skills/frontend-design-review/SKILL.md +5 -3
  53. package/skills/frontend-design-review/references/review-checklist.md +3 -2
  54. package/skills/frontend-implementation/references/design-spec.md +16 -8
  55. package/skills/frontend-review/SKILL.md +7 -1
  56. package/skills/frontend-review/references/review-findings.md +5 -1
  57. package/skills/frontend-verification/SKILL.md +5 -3
  58. package/skills/frontend-verification/references/verification-checklist.md +3 -2
  59. package/skills/loop-agent/references/command-reference.md +3 -0
  60. package/skills/playwright-cli-case-generator/SKILL.md +35 -7
  61. package/dist/worker/console/static/assets/index-3vsjZJHq.js +0 -16
  62. package/dist/worker/console/static/assets/index-i1wV4LrY.css +0 -1
@@ -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))}}@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-3vsjZJHq.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-i1wV4LrY.css">
7
+ <script type="module" crossorigin src="/assets/index-CUDke82y.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-wSEksVSO.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -1574,6 +1574,39 @@ async function parseDagStateFile(statePath, now) {
1574
1574
  })
1575
1575
  : undefined;
1576
1576
  const backendTest = await loadBackendTestProjection(runDir, nodes);
1577
+ const continuationRecord = state.continuation &&
1578
+ typeof state.continuation.parentRunId === "string" &&
1579
+ typeof state.continuation.effectiveFromNodeId === "string" &&
1580
+ typeof state.continuation.operatorSelectedNodeId === "string"
1581
+ ? state.continuation
1582
+ : undefined;
1583
+ const rerunPlan = continuationRecord
1584
+ ? await safeReadJson(path.join(runDir, ".runtime", "rerun-plan.json"))
1585
+ : undefined;
1586
+ const verification = rerunPlan && typeof rerunPlan.verificationPolicy === "object"
1587
+ ? rerunPlan.verificationPolicy
1588
+ : undefined;
1589
+ const continuation = continuationRecord
1590
+ ? {
1591
+ parentRunId: continuationRecord.parentRunId,
1592
+ effectiveFromNodeId: continuationRecord.effectiveFromNodeId,
1593
+ operatorSelectedNodeId: continuationRecord.operatorSelectedNodeId,
1594
+ ...(verification &&
1595
+ (verification.mode === "inherited" || verification.mode === "rerun") &&
1596
+ Array.isArray(verification.nodeIds) &&
1597
+ verification.nodeIds.every((nodeId) => typeof nodeId === "string")
1598
+ ? {
1599
+ verificationPolicy: {
1600
+ mode: verification.mode,
1601
+ nodeIds: verification.nodeIds,
1602
+ ...(typeof verification.inheritedFromRunId === "string"
1603
+ ? { inheritedFromRunId: verification.inheritedFromRunId }
1604
+ : {}),
1605
+ },
1606
+ }
1607
+ : {}),
1608
+ }
1609
+ : undefined;
1577
1610
  return {
1578
1611
  dagRunId,
1579
1612
  status,
@@ -1590,6 +1623,7 @@ async function parseDagStateFile(statePath, now) {
1590
1623
  nodes,
1591
1624
  edges,
1592
1625
  dagPath: runDir,
1626
+ ...(continuation ? { continuation } : {}),
1593
1627
  ...(backendTest ? { backendTest } : {}),
1594
1628
  };
1595
1629
  }
@@ -1699,6 +1733,10 @@ function parseDagNodes(parsed, rankByNode, modelByNode) {
1699
1733
  const nodeStatus = readString(node, "status");
1700
1734
  const outputPreview = nodeOutputPreview(node);
1701
1735
  const errorPreview = nodeErrorPreview(node, nodeStatus);
1736
+ const attempts = Array.isArray(node.attempts) ? node.attempts : undefined;
1737
+ const origin = node.origin && typeof node.origin === "object" && !Array.isArray(node.origin)
1738
+ ? node.origin
1739
+ : undefined;
1702
1740
  nodes.push({
1703
1741
  nodeId: resolvedId,
1704
1742
  rank: rankByNode.get(nodeId) ?? rankByNode.get(resolvedId),
@@ -1711,6 +1749,15 @@ function parseDagNodes(parsed, rankByNode, modelByNode) {
1711
1749
  finishedAt: readString(node, "finishedAt"),
1712
1750
  ...(outputPreview ? { outputPreview } : {}),
1713
1751
  ...(errorPreview ? { errorPreview } : {}),
1752
+ ...(attempts && attempts.length > 0
1753
+ ? { attemptCount: attempts.length }
1754
+ : {}),
1755
+ ...(readString(node, "failureCategory")
1756
+ ? { failureCategory: readString(node, "failureCategory") }
1757
+ : {}),
1758
+ ...(origin?.kind === "imported" || origin?.kind === "executed"
1759
+ ? { originKind: origin.kind }
1760
+ : {}),
1714
1761
  });
1715
1762
  }
1716
1763
  return nodes.sort((a, b) => a.nodeId.localeCompare(b.nodeId));
@@ -1721,6 +1768,10 @@ function parseDagNodes(parsed, rankByNode, modelByNode) {
1721
1768
  const nodeStatus = readString(node, "status");
1722
1769
  const outputPreview = nodeOutputPreview(node);
1723
1770
  const errorPreview = nodeErrorPreview(node, nodeStatus);
1771
+ const attempts = Array.isArray(node.attempts) ? node.attempts : undefined;
1772
+ const origin = node.origin && typeof node.origin === "object" && !Array.isArray(node.origin)
1773
+ ? node.origin
1774
+ : undefined;
1724
1775
  nodes.push({
1725
1776
  nodeId,
1726
1777
  rank: rankByNode.get(nodeId),
@@ -1733,6 +1784,15 @@ function parseDagNodes(parsed, rankByNode, modelByNode) {
1733
1784
  finishedAt: readString(node, "finishedAt"),
1734
1785
  ...(outputPreview ? { outputPreview } : {}),
1735
1786
  ...(errorPreview ? { errorPreview } : {}),
1787
+ ...(attempts && attempts.length > 0
1788
+ ? { attemptCount: attempts.length }
1789
+ : {}),
1790
+ ...(readString(node, "failureCategory")
1791
+ ? { failureCategory: readString(node, "failureCategory") }
1792
+ : {}),
1793
+ ...(origin?.kind === "imported" || origin?.kind === "executed"
1794
+ ? { originKind: origin.kind }
1795
+ : {}),
1736
1796
  });
1737
1797
  }
1738
1798
  return nodes.sort((a, b) => a.nodeId.localeCompare(b.nodeId));
@@ -47,6 +47,23 @@ function isSpecFilePath(filePath) {
47
47
  function isKnowledgeBaseTool(toolName) {
48
48
  return KB_CONNECTOR_TOOLS.has(toolName);
49
49
  }
50
+ /** A repo-relative path references <repoRoot>/openspec/**. */
51
+ function isOpenspecPath(filePath) {
52
+ const normalized = filePath.replaceAll(path.sep, "/");
53
+ return normalized === "openspec" || normalized.startsWith("openspec/");
54
+ }
55
+ /** A search query targets the openspec/ directory. */
56
+ 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/*"));
66
+ }
50
67
  function resolveSessionEventsPath(repoRoot, dagRunId, nodeId) {
51
68
  if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
52
69
  return null;
@@ -278,6 +295,9 @@ export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
278
295
  }
279
296
  // Search/scan tool calls (grep, find, ls, glob)
280
297
  if (["grep", "find", "ls", "glob"].includes(toolName)) {
298
+ const searchPath = typeof pairedInput.path === "string"
299
+ ? pairedInput.path
300
+ : undefined;
281
301
  const query = typeof pairedInput.pattern === "string"
282
302
  ? pairedInput.pattern
283
303
  : typeof pairedInput.query === "string"
@@ -288,6 +308,7 @@ export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
288
308
  searches.push({
289
309
  tool: toolName,
290
310
  query,
311
+ path: searchPath,
291
312
  timestamp: ts,
292
313
  });
293
314
  if (toolCallId)
@@ -341,6 +362,16 @@ export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
341
362
  if (status === "no-evidence") {
342
363
  summaryLines.push("未观察到任何规范证据:无 skill 注入、无文件读取、无检索操作。");
343
364
  }
365
+ // Separate openspec/** reads and searches for Dashboard display.
366
+ // Knowledge base and openspec are parallel sources.
367
+ const openspecReads = specReads.filter((r) => isOpenspecPath(r.path));
368
+ const openspecSearches = searches.filter((s) => isOpenspecSearch(s.query, s.path));
369
+ if (openspecReads.length > 0) {
370
+ summaryLines.push(`openspec 已读取 ${openspecReads.length} 个文件:${openspecReads.map((r) => r.path).join("、")}`);
371
+ }
372
+ if (openspecSearches.length > 0) {
373
+ summaryLines.push(`openspec 检索 ${openspecSearches.length} 次。`);
374
+ }
344
375
  return {
345
376
  dagRunId,
346
377
  nodeId,
@@ -352,6 +383,8 @@ export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
352
383
  specReads,
353
384
  specSearches: searches,
354
385
  knowledgeBaseQueries: kbQueries,
386
+ openspecReads,
387
+ openspecSearches,
355
388
  summary: summaryLines.join(" "),
356
389
  };
357
390
  }
@@ -15,7 +15,7 @@
15
15
  <body data-shell="observe-console">
16
16
  <a class="skip-link" href="#main-content">跳到主内容</a>
17
17
  <nav class="embedded-operator-nav" aria-label="Operator 导航">
18
- <a href="/">操作</a>
18
+ <a href="/">返回操作台</a>
19
19
  <span aria-current="page">观测</span>
20
20
  </nav>
21
21
  <div id="app">
@@ -233,10 +233,15 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
233
233
  }
234
234
  content.appendChild(statusSection);
235
235
 
236
- const appendListSection = (title, entries, renderEntry) => {
237
- if (!entries?.length) return;
236
+ const appendListSection = (title, entries, renderEntry, emptyMessage = "") => {
237
+ if (!entries?.length && !emptyMessage) return;
238
238
  const section = el("div", "spec-evidence-section");
239
239
  section.appendChild(el("h4", null, title));
240
+ if (!entries?.length) {
241
+ section.appendChild(el("p", "spec-evidence-summary", emptyMessage));
242
+ content.appendChild(section);
243
+ return;
244
+ }
240
245
  const list = el("ul", "spec-evidence-list");
241
246
  for (const entry of entries) list.appendChild(renderEntry(entry));
242
247
  section.appendChild(list);
@@ -273,9 +278,15 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
273
278
  return item;
274
279
  });
275
280
  appendListSection("显式需求编号", evidence.sourceBinding?.requirementIds, (id) => el("li", null, id));
281
+ const openspecReadPaths = new Set(
282
+ (evidence.openspecReads ?? []).map((entry) => entry.path),
283
+ );
284
+ const otherSpecReads = (evidence.specReads ?? []).filter(
285
+ (entry) => !openspecReadPaths.has(entry.path),
286
+ );
276
287
  appendListSection(
277
- `已读取规范文件(${evidence.specReads?.length ?? 0})`,
278
- evidence.specReads,
288
+ `其他已读取规范文件(${otherSpecReads.length})`,
289
+ otherSpecReads,
279
290
  (read) => {
280
291
  const item = el("li", null);
281
292
  const button = document.createElement("button");
@@ -327,6 +338,58 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
327
338
  item.append(icon, document.createTextNode(` ${query.connector}`));
328
339
  return item;
329
340
  },
341
+ "unavailable:未观察到知识库查询;connector 可能未配置或未调用。",
342
+ );
343
+
344
+ // openspec reads are displayed independently from skill/docs reads.
345
+ appendListSection(
346
+ `openspec 已读取(${evidence.openspecReads?.length ?? 0})`,
347
+ evidence.openspecReads,
348
+ (read) => {
349
+ const item = el("li", null);
350
+ const button = document.createElement("button");
351
+ button.type = "button";
352
+ button.className = "spec-evidence-file-btn";
353
+ button.setAttribute("data-source", "read");
354
+ button.setAttribute("data-path", read.path);
355
+ if (read.timestamp) {
356
+ button.setAttribute("data-read-at", read.timestamp);
357
+ }
358
+ button.id = `spec-evidence-btn-read-${read.path}`;
359
+ const icon = el("i", "ri-book-open-line");
360
+ icon.setAttribute("aria-hidden", "true");
361
+ button.append(icon, el("code", null, read.path));
362
+ if (read.timestamp) {
363
+ button.appendChild(
364
+ el(
365
+ "span",
366
+ "spec-evidence-time",
367
+ formatSessionEventTime({ timestamp: read.timestamp }),
368
+ ),
369
+ );
370
+ }
371
+ button.addEventListener("click", () =>
372
+ onSpecEvidenceFileClick(content, dagRunId, nodeId, "read", read.path, button),
373
+ );
374
+ item.appendChild(button);
375
+ return item;
376
+ },
377
+ evidence.openspecSearches?.length
378
+ ? "已检索但未观察到 openspec 成功读取;若生成期存在候选,预写门禁会阻断 writer。"
379
+ : "unavailable:未观察到 openspec 成功读取。",
380
+ );
381
+
382
+ appendListSection(
383
+ `openspec 检索(${evidence.openspecSearches?.length ?? 0})`,
384
+ evidence.openspecSearches,
385
+ (search) => {
386
+ const item = el("li", null);
387
+ const icon = el("i", "ri-search-line");
388
+ icon.setAttribute("aria-hidden", "true");
389
+ item.append(icon, document.createTextNode(` ${search.tool}: ${search.query}`));
390
+ return item;
391
+ },
392
+ "unavailable:未观察到 openspec 检索。",
330
393
  );
331
394
 
332
395
  if (evidence.status === "source-bound" || evidence.status === "spec-injected" || evidence.status === "no-evidence") {
@@ -117,6 +117,13 @@ export async function runTaskSpec(options) {
117
117
  options.repoRoot,
118
118
  "--run-id",
119
119
  workerRunId,
120
+ "--worker-association",
121
+ JSON.stringify({
122
+ schemaVersion: 1,
123
+ workerRunId,
124
+ featureId: options.taskSpec.feature_id,
125
+ taskId: options.taskSpec.id,
126
+ }),
120
127
  "--events-jsonl",
121
128
  dagEventsPath,
122
129
  ...maxConcurrentArgs(options.taskSpec),
@@ -30,6 +30,80 @@ function firstVerdictLine(text) {
30
30
  }
31
31
  return "";
32
32
  }
33
+ function eventArgs(event) {
34
+ return event.args ?? event.toolInput ?? event.input ?? {};
35
+ }
36
+ function toRepoRelativePath(filePath, repoRoot) {
37
+ const resolved = path.resolve(repoRoot, filePath);
38
+ const relative = path.relative(repoRoot, resolved);
39
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
40
+ return null;
41
+ }
42
+ return relative.replaceAll(path.sep, "/");
43
+ }
44
+ function isFailedToolResult(event) {
45
+ return (event.isError === true ||
46
+ event.toolResult?.error != null ||
47
+ event.toolResult?.ok === false ||
48
+ event.result?.error != null ||
49
+ event.result?.ok === false);
50
+ }
51
+ async function checkOpenspecReadEvidence(input) {
52
+ const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
53
+ if (candidatePaths.length === 0)
54
+ return [];
55
+ const matched = new Set();
56
+ const normalizedCandidates = new Set(candidatePaths
57
+ .map((candidate) => toRepoRelativePath(candidate, repoRoot))
58
+ .filter((candidate) => Boolean(candidate?.startsWith("openspec/"))));
59
+ for (const nodeId of [planNodeId, reviewNodeId]) {
60
+ const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
61
+ try {
62
+ const raw = await readFile(eventsPath, "utf8");
63
+ const startedReads = new Map();
64
+ for (const line of raw.split(/\r?\n/)) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed)
67
+ continue;
68
+ let event;
69
+ try {
70
+ event = JSON.parse(trimmed);
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ if (event.type === "tool_execution_start" &&
76
+ event.toolName === "read" &&
77
+ event.toolCallId) {
78
+ const readPath = eventArgs(event).path;
79
+ if (typeof readPath === "string") {
80
+ startedReads.set(event.toolCallId, readPath);
81
+ }
82
+ }
83
+ else if (event.type === "tool_execution_end" &&
84
+ event.toolName === "read" &&
85
+ event.toolCallId !== undefined) {
86
+ const readPath = startedReads.get(event.toolCallId);
87
+ const relativePath = readPath
88
+ ? toRepoRelativePath(readPath, repoRoot)
89
+ : null;
90
+ if (relativePath &&
91
+ !isFailedToolResult(event) &&
92
+ normalizedCandidates.has(relativePath)) {
93
+ matched.add(relativePath);
94
+ }
95
+ startedReads.delete(event.toolCallId);
96
+ }
97
+ }
98
+ }
99
+ catch (error) {
100
+ if (error.code === "ENOENT")
101
+ continue;
102
+ throw error;
103
+ }
104
+ }
105
+ return [...matched];
106
+ }
33
107
  export async function runFrontendPrewriteGate(input) {
34
108
  const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
35
109
  const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
@@ -55,6 +129,18 @@ export async function runFrontendPrewriteGate(input) {
55
129
  if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {
56
130
  throw new Error(`frontend prewrite gate blocked mock strategy ${contract.mockApi.strategy}; allowed=${input.config.allowedMockStrategies.join(",")}`);
57
131
  }
132
+ const candidatePaths = input.config.openspecCandidatePaths ?? [];
133
+ const openspecReadPaths = await checkOpenspecReadEvidence({
134
+ runDir: input.runDir,
135
+ candidatePaths,
136
+ planNodeId,
137
+ reviewNodeId,
138
+ repoRoot: input.repoRoot ?? process.cwd(),
139
+ });
140
+ if (candidatePaths.length > 0 && openspecReadPaths.length === 0) {
141
+ const checkedNodes = [planNodeId, reviewNodeId].join(", ");
142
+ throw new Error(`openspec gate blocked: ${candidatePaths.length} candidate(s) [${candidatePaths.join(", ")}] not read by ${checkedNodes}; writer not authorized.`);
143
+ }
58
144
  return {
59
145
  ok: true,
60
146
  planNodeId,
@@ -62,10 +148,12 @@ export async function runFrontendPrewriteGate(input) {
62
148
  verdict,
63
149
  mockStrategy: contract.mockApi.strategy,
64
150
  artifact,
151
+ openspecReadPaths,
152
+ openspecCandidatePaths: candidatePaths,
65
153
  };
66
154
  }
67
155
  export function formatFrontendPrewriteGateStdout(result) {
68
- return [
156
+ const lines = [
69
157
  "Frontend prewrite gate: pass",
70
158
  `Plan: ${result.planNodeId}`,
71
159
  `Review: ${result.reviewNodeId}`,
@@ -73,5 +161,12 @@ export function formatFrontendPrewriteGateStdout(result) {
73
161
  `Structured artifact: ${result.artifact.path}`,
74
162
  `Schema: ${result.artifact.schemaId}`,
75
163
  `SHA-256: ${result.artifact.sha256}`,
76
- ].join("\n");
164
+ ];
165
+ if (result.openspecReadPaths.length > 0) {
166
+ lines.push(`openspec read: ${result.openspecReadPaths.join(", ")}`);
167
+ }
168
+ else if (result.openspecCandidatePaths.length === 0) {
169
+ lines.push("openspec: unavailable");
170
+ }
171
+ return lines.join("\n");
77
172
  }
@@ -105,6 +105,7 @@ export async function discoverFrontendProjectCapability(repoRoot) {
105
105
  const pkgPath = path.join(repoRoot, "package.json");
106
106
  const pkgRaw = await readJson(pkgPath);
107
107
  if (!pkgRaw || typeof pkgRaw !== "object") {
108
+ const openspec = await listOpenspec(repoRoot);
108
109
  const base = {
109
110
  schemaVersion: 1,
110
111
  framework: "unknown",
@@ -115,14 +116,17 @@ export async function discoverFrontendProjectCapability(repoRoot) {
115
116
  testRunner: [],
116
117
  mock: [],
117
118
  a11y: { status: "unknown", tools: [], evidencePaths: [] },
118
- evidencePaths: [],
119
+ evidencePaths: openspec.slice(0, 5),
119
120
  reasons: ["package.json missing or unreadable"],
120
121
  designEvidence: {
121
- normativePaths: [],
122
+ normativePaths: openspec,
122
123
  advisoryPaths: [],
123
124
  conflicts: [],
124
125
  },
125
126
  };
127
+ if (openspec.length) {
128
+ base.reasons.push(`openspec/ files discovered: ${openspec.slice(0, 5).join(", ")}`);
129
+ }
126
130
  return { ...base, adapterGuidance: buildAdapterGuidance(base) };
127
131
  }
128
132
  evidencePaths.push("package.json");
@@ -185,3 +185,67 @@ export function buildFrontendTestOutcomeGateShellSnippet(options) {
185
185
  `node -e 'const fs=require("fs");const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const ok=r.outcome==="passed"&&r.integrationMode==="real"&&Number(r.totals?.failed||0)===0&&Number(r.totals?.blocked||0)===0&&Array.isArray(r.acceptanceCoverage?.missing)&&r.acceptanceCoverage.missing.length===0;console.log("frontend-test outcome="+r.outcome+" integrationMode="+r.integrationMode);if(!ok)process.exit(1);' "\${RESULT}"`,
186
186
  ].join("; ");
187
187
  }
188
+ /**
189
+ * Shared frontend-test evidence gate for map children + node 7.
190
+ * - Hard fail only on manifest/path integrity (unsafe evidenceDir / escape).
191
+ * - Missing or malformed case-result/execution is healed to status=blocked
192
+ * with blockedReason=invalid-evidence-shape so result materialize + retrospect can run.
193
+ */
194
+ export function buildFrontendCaseEvidenceValidateShellSnippet() {
195
+ const body = [
196
+ "const fs=require('fs'),path=require('path');",
197
+ "const manifestPath='testcase/frontend/cases/manifest.json';",
198
+ "if(!fs.existsSync(manifestPath))throw new Error('missing '+manifestPath);",
199
+ "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
200
+ "if(!Array.isArray(manifest.cases))throw new Error('invalid frontend case manifest');",
201
+ "const statuses=new Set(['passed','failed','blocked']);",
202
+ "const issues=[];",
203
+ "let hardFail=false;",
204
+ "let evidenceContentCount=0;",
205
+ "function hasEvidenceContent(dir){if(!fs.existsSync(dir))return false;const pending=[dir];while(pending.length){const current=pending.pop();for(const entry of fs.readdirSync(current,{withFileTypes:true})){const candidate=path.join(current,entry.name);if(entry.isDirectory()){pending.push(candidate);}else if(entry.isFile()&&fs.statSync(candidate).size>0){return true;}}}return false;}",
206
+ "function writeBlocked(dir,caseId,reason,detail){",
207
+ " fs.mkdirSync(dir,{recursive:true});",
208
+ " const execution=path.join(dir,'execution.md');",
209
+ " const note='# '+caseId+'\\n\\nStatus: blocked\\n\\nReason: '+reason+(detail?('\\n\\nDetail: '+detail):'')+'\\n';",
210
+ " fs.writeFileSync(execution,note);",
211
+ " fs.writeFileSync(path.join(dir,'case-result.json'),JSON.stringify({caseId:caseId,status:'blocked',blockedReason:reason,evidencePaths:['execution.md']},null,2)+'\\n');",
212
+ "}",
213
+ "function isSafeRel(p){return typeof p==='string'&&p.length>0&&!path.isAbsolute(p)&&!p.includes('..');}",
214
+ "for(const c of manifest.cases){",
215
+ " const id=c&&typeof c.caseId==='string'?c.caseId:'?';",
216
+ " const dir=c&&c.evidenceDir;",
217
+ " const prefix='testcase/frontend/evidence/'+id;",
218
+ " if(!c||typeof c.caseId!=='string'||typeof dir!=='string'||!isSafeRel(dir)||!(dir===prefix||dir.startsWith(prefix+'/'))){",
219
+ " hardFail=true; issues.push({ruleId:'unsafe-evidence-dir',caseId:id,detail:String(dir)}); continue;",
220
+ " }",
221
+ " const execution=path.join(dir,'execution.md');",
222
+ " const resultPath=path.join(dir,'case-result.json');",
223
+ " if(hasEvidenceContent(dir))evidenceContentCount++;",
224
+ " let healReason=null; let healDetail=null;",
225
+ " if(!fs.existsSync(execution)){healReason='invalid-evidence-shape';healDetail='missing execution.md';}",
226
+ " if(!fs.existsSync(resultPath)){healReason='invalid-evidence-shape';healDetail=(healDetail?healDetail+'; ':'')+'missing case-result.json';}",
227
+ " let result=null;",
228
+ " if(!healReason){",
229
+ " try{result=JSON.parse(fs.readFileSync(resultPath,'utf8'));}catch(e){healReason='invalid-evidence-shape';healDetail='invalid JSON case-result.json';}",
230
+ " }",
231
+ " if(!healReason){",
232
+ " if(!result||result.caseId!==id||!statuses.has(result.status)||!Array.isArray(result.evidencePaths)||result.evidencePaths.some(p=>!isSafeRel(p))){",
233
+ " healReason='invalid-evidence-shape';healDetail='caseId/status/evidencePaths contract';",
234
+ " } else if(result.status==='passed'&&(result.evidencePaths.length<1||!result.evidencePaths.some(p=>/\\.(png|jpg|jpeg|webp|zip|har|webm|mp4|md)$/i.test(p)))){",
235
+ " healReason='invalid-evidence-shape';healDetail='passed requires browser evidence path';",
236
+ " } else if(result.status==='blocked'&&(typeof result.blockedReason!=='string'||!result.blockedReason.trim())){",
237
+ " healReason='invalid-evidence-shape';healDetail='blocked requires blockedReason';",
238
+ " }",
239
+ " }",
240
+ " if(healReason){",
241
+ " writeBlocked(dir,id,healReason,healDetail);",
242
+ " issues.push({ruleId:healReason,caseId:id,detail:healDetail,healed:true});",
243
+ " }",
244
+ "}",
245
+ "if(hardFail){console.error('frontend-test evidence hard-fail: '+JSON.stringify(issues)); process.exit(1);}",
246
+ "if(evidenceContentCount===0){console.error('frontend-test evidence hard-fail: no frontend case evidence content'); process.exit(1);}",
247
+ "const healed=issues.filter(i=>i.healed).length;",
248
+ "console.log('frontend case evidence validation ok cases='+manifest.cases.length+' healed='+healed+(issues.length?(' issues='+JSON.stringify(issues)):''));",
249
+ ].join("");
250
+ return ["node -e", JSON.stringify(body)].join(" ");
251
+ }